diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..899ce25
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+
+jobs:
+ shellcheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install shellcheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+ - name: Lint (errors)
+ run: |
+ shopt -s globstar nullglob
+ shellcheck -S error \
+ setup.sh uninstall.sh update.sh \
+ src/emerger.sh src/lib/*.sh
+ - name: Lint (warnings, non-blocking)
+ run: |
+ shopt -s globstar nullglob
+ shellcheck -S warning \
+ setup.sh uninstall.sh update.sh \
+ src/emerger.sh src/lib/*.sh || true
+
+ tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install bats
+ run: sudo apt-get update && sudo apt-get install -y bats
+ - name: Run bats
+ run: bats tests/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 14dc49c..0c95530 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,59 +1,52 @@
-Contributions to this project are very appreciated, though we require some formalisms:
-
- -
- You are not required to explain everything you did inside code comments. But when you are creating code that spans between multiple files, or it requires more than basic knowledge to be understood, help everyone understand what you are doing by leaving small comments inside your code or by explaining the stream of consciousness inside the pull request.
-
- -
- When creating a pull request, point to an issue or explain what you did using few words (this is an extension of what was said in rule 1).
-
- -
- Not following step 1 and step 2, will not exclude your contribution but it will slow the development process because we will have to make questions before accepting your request.
-
- -
- We will not accept contributions containing shady code or any form of crime.
- Examples of shady code is, but not limited to:
-
- -
- References and links to servers we don't own or that are not certified
-
- -
- Exposing users to external or internal vulnerabilities
-
- -
- Using code tricks to bypass privileges or access reserved memory areas
-
-
- Examples of crimes are, but not limited to:
-
- -
- Discrimination based on ethnicity, sex, religion, physical aspect, sexual orientation or any other individual right protected by Human Rights or by simple logic and maturity
-
- -
- Cybercrimes
-
-
-
- -
- It's not mandatory, but try to follow the already existing code indentation.
-
- -
- Since we believe in a friendly enviroment, memes are permitted in comments. Just don't abuse them and respect rule 4.
-
- -
- You can contribute using your licensed code, though we will have to check few things before:
-
- -
- You are the real author
-
- -
- The contribution respects rule 4
-
- -
- If you propose someone else's code to be integrated, the integration will have to respect the author's needs and will. Basically we have to respect its license.
-
-
-
- -
- Last but not least, no one on this planet is born knowning everything. If you respect all the rules above but your pull request is rejected because of the nature of your code, your contribution will not be vain. Your help may give someone else ideas, or the possibility to refactor your code and make it more readable. This is the meaning of open source. Also remember that even us, the creators, started all of this knowing almost nothing, so don't be afraid to be judged.
-
-
\ No newline at end of file
+# Contributing
+
+First of all, thanks for being here! Contributions are very appreciated, but we ask you to follow a few simple rules so everyone can enjoy the ride.
+
+## 1. Comment your code (just a little)
+
+You don't need to explain every single line. But if your change spans multiple files, or it takes more than basic knowledge to understand, please leave a few small comments or share your stream of consciousness in the pull request. Future us (and you) will be grateful.
+
+## 2. Write meaningful pull requests
+
+When opening a PR, link to an issue or describe what you did in a few words. Think of it as an extension of rule 1.
+
+## 3. About rules 1 and 2
+
+Skipping them won't get your contribution rejected, but it will slow things down, since we'll have to ask questions before merging.
+
+## 4. No shady code, no crimes
+
+We will not accept contributions containing *shady code* or any form of *crime*.
+
+Examples of *shady code* (not limited to):
+
+- References and links to servers we don't own or that are not certified
+- Exposing users to external or internal vulnerabilities
+- Code tricks to bypass privileges or access reserved memory areas
+
+Examples of *crimes* (not limited to):
+
+- Discrimination based on ethnicity, sex, religion, physical aspect, sexual orientation, or any other individual right protected by Human Rights or by simple logic and maturity
+- Cybercrimes
+
+## 5. Mind the indentation
+
+Not mandatory, but please try to match the existing code style. It keeps the codebase tidy.
+
+## 6. Memes are welcome
+
+We believe in a friendly environment, so memes in comments are totally fine. Just don't overdo it, and always respect rule 4.
+
+## 7. Licensed code
+
+You can contribute with your licensed code, but we'll have to check a few things first:
+
+1. You are the real author.
+2. The contribution respects rule 4.
+3. If you propose someone else's code, the integration must respect the author's needs and license.
+
+## 8. Don't be afraid
+
+No one on this planet is born knowing everything. If you follow all the rules above but your PR gets rejected because of the nature of your code, your effort won't be in vain: your work may give someone else ideas, or the chance to refactor it into something even better. That's the beauty of open source.
+
+And remember, even us, the creators, started out knowing almost nothing. So really, don't be afraid of being judged. Just jump in.
diff --git a/README.md b/README.md
index 7aaecae..bb5c0c0 100644
--- a/README.md
+++ b/README.md
@@ -1,86 +1,1513 @@
-[](https://img.shields.io/github/languages/code-size/MasterCruelty/eMerger)
+# eMerger
+
+[](https://github.com/MasterCruelty/eMerger/actions/workflows/ci.yml)
[](https://sonarcloud.io/summary/new_code?id=TheMergers_eMerger)
-[](https://img.shields.io/github/issues/MasterCruelty/eMerger)
-[](https://img.shields.io/github/languages/top/MasterCruelty/eMerger)
-[](https://sonarcloud.io/summary/new_code?id=TheMergers_eMerger)
-[](https://sonarcloud.io/summary/new_code?id=TheMergers_eMerger)
-[](https://img.shields.io/github/commit-activity/w/MasterCruelty/eMerger)
-[](https://img.shields.io/github/commits-since/MasterCruelty/emerger/latest?color=44CC11&style=flat-square)
-
-eMerger
+[](https://sonarcloud.io/summary/new_code?id=TheMergers_eMerger)
+
-
+
-What is it?
-eMerger is a simple script to clean update your system and your packages by just typing up in your terminal!
-
-Systems tested and working
-
- - Arch Linux
- - Debian
- - EndeavourOS
- - Fedora
- - Kali
- - Manjaro
- - Raspbian
- - Termux
- - Ubuntu
-
-
-Systems tested and not working (help wanted)
-
-
-Supported package managers
-
- - apt
- - apt-get
- - dnf
- - emerge
- - flatpak
- - nixos
- - pacman
- - pkg
- - rpm
- - snap
- - yay
- - yum
- - zypper
-
-
----
-
-Install
-
- - Run
./setup.sh
- - Run
up
-
-If you cloned using root privileges, and you want to execute without them, remember to run: sudo chown -R yourusername .
-Uninstall
-
- - Run
./uninstall.sh
-
-Update
-You have three options:
-
- - Run
./update.sh
- - Run
up -up
- - Run
up -au to set a cronjob
-
-Usage
-eMerger comes with inline arguments: just type up -help to explore them.
-
----
-
-Contribute
-How to contribute
-Issue
-Is there a problem? ๐ฅ๏ธ
-Your package manager is not listed? ๐ฅ๏ธ
-Feel free to open an issue. Try to explain exactly what happens and if possible post errors or outputs you managed to retrieve.
-License
- This project license can be found in ./LICENSE
-External projects used
-wttr.in
+**One command - `up` - refreshes, upgrades and cleans your whole system.**
+Works on **Linux**, **macOS** and **Windows**. Auto-detects every package
+manager you have, runs them in the right order, gives you one clean summary
+at the end. No YAML, no daemons.
+
+> New to package managers? Read the [Why eMerger?](#why-emerger) section
+> first. In a hurry? Jump to [Quickstart](#quickstart).
+
+---
+
+## Table of contents
+
+1. [Why eMerger?](#why-emerger)
+2. [Platforms at a glance](#platforms-at-a-glance)
+3. [Quickstart](#quickstart)
+4. [What `up` actually does](#what-up-actually-does)
+5. [Requirements](#requirements)
+6. [Installation](#installation)
+ - [Linux](#install-linux)
+ - [macOS](#install-macos)
+ - [Windows](#install-windows)
+7. [Uninstallation](#uninstallation)
+8. [Update / self-update](#update--self-update)
+9. [User manual](#user-manual)
+ - [Running](#running)
+ - [Flags, quick reference](#flags-quick-reference)
+ - [Combining flags](#combining-flags)
+ - [Interactive mode](#interactive-mode)
+ - [Dry-run & verbose](#dry-run--verbose)
+ - [Quiet levels](#quiet-levels)
+ - [Security-only updates](#security-only-updates)
+ - [Dev toolchains](#dev-toolchains)
+ - [Firmware (Linux)](#firmware-linux)
+ - [Parallel mode](#parallel-mode)
+ - [Snapshots (Linux)](#snapshots-linux)
+ - [Mirrors refresh (Linux)](#mirrors-refresh-linux)
+ - [Reboot handling](#reboot-handling)
+ - [Resume after interruption](#resume-after-interruption)
+ - [Package diff & changelog](#package-diff--changelog)
+ - [Reports](#reports)
+ - [History & errors](#history--errors)
+ - [Doctor](#doctor)
+10. [Configuration](#configuration)
+ - [config file](#config-file)
+ - [All config variables](#all-config-variables)
+ - [Profiles](#profiles)
+ - [Hooks](#hooks)
+ - [Ignore list (Linux)](#ignore-list-linux)
+ - [Quiet hours](#quiet-hours)
+ - [Manager plugins](#manager-plugins)
+11. [Integration](#integration)
+ - [JSON output](#json-output)
+ - [Prometheus metrics](#prometheus-metrics)
+ - [Reboot exit code](#reboot-exit-code)
+ - [Download-only / offline](#download-only--offline)
+ - [Manager filtering](#manager-filtering)
+ - [Rollback](#rollback)
+ - [Short flag bundling](#short-flag-bundling)
+12. [Auto-update (unattended)](#auto-update-unattended)
+13. [Cookbook / Recipes](#cookbook--recipes)
+14. [Safety & security](#safety--security)
+15. [Files & paths](#files--paths)
+16. [Supported package managers](#supported-package-managers)
+17. [Exit codes](#exit-codes)
+18. [Troubleshooting](#troubleshooting)
+19. [FAQ](#faq)
+20. [Glossary](#glossary)
+21. [Development](#development)
+22. [License](#license)
+
+๐ **For a printable, all-in-one reference see
+[doc/documentation.pdf](./doc/documentation.pdf).**
+
+---
+
+## Why eMerger?
+
+A modern machine gets its software from many different places at once:
+the distro's own package manager (`apt`, `dnf`, `pacman`, `zypper`,
+`softwareupdate`, `winget`...), an app-store layer (`flatpak`, `snap`,
+`brew --cask`, `choco`, `scoop`), a user-level store (`brew`, `mas`),
+language ecosystems (`npm`, `pip`, `cargo`, `gem`, `pnpm`...) and, on
+Linux, firmware via `fwupdmgr`. Each has its own syntax, its own cache,
+its own notion of "security update" and its own definition of "clean".
+
+Keeping all of them up to date by hand is tedious and error-prone.
+Writing a personal wrapper that works on three operating systems is a
+weekend project most people never finish.
+
+**eMerger is that wrapper, generalised.** Type `up` and it will:
+
+1. detect every package manager installed on the host
+2. ask for `sudo` (or trigger UAC) only if something actually needs it
+3. take an optional snapshot so you can audit the run afterwards
+4. run refresh / upgrade / clean, in the right order, with retries
+5. wipe user caches and the trash, optionally
+6. print one summary with per-manager result, disk freed, run duration
+ and a reboot advisory
+7. export the same summary as JSON, Markdown or a Prometheus file
+
+It does this with no daemon, no YAML, no Python/Ruby runtime. Just Bash
+on Unix, PowerShell on Windows.
+
+**What eMerger is NOT:**
+
+- Not a package manager itself. It drives the ones you already have.
+- Not a configuration management system. No desired state, no manifest.
+ If you want that, use Ansible, Salt or NixOS.
+- Not a service. It can install a weekly timer, but it does not run in
+ the background.
+
+---
+
+## Platforms at a glance
+
+| | Linux | macOS | Windows |
+|---|---|---|---|
+| Entry point | `src/emerger.sh` | `src/emerger.sh` | `src/emerger.ps1` |
+| Shell | bash 3.2+ | bash 3.2+ (system default) | PowerShell 5.1+ |
+| Setup | `./setup.sh` | `./setup.sh` | `.\setup.ps1` |
+| Uninstall | `./uninstall.sh` | `./uninstall.sh` | `.\uninstall.ps1` |
+| Auto-update | systemd user timer / cron | launchd-compat cron | Task Scheduler |
+| Elevation | `sudo` | `sudo` | UAC relaunch |
+| TUI menu | yes (`-i`) | yes (`-i`) | no |
+| Parallel mode | yes | yes | no (always serial) |
+| Snapshots | snapper/timeshift/btrfs | no | no (System Restore is manual) |
+| Config dir | `~/.config/emerger/` | `~/.config/emerger/` | `%APPDATA%\emerger\` |
+| State dir | `~/.local/state/emerger/` | `~/.local/state/emerger/` | `%LOCALAPPDATA%\emerger\state\` |
+
+Feature parity is kept for the core flow (detect โ upgrade โ clean โ summary).
+Platform-specific features are documented below and clearly labelled.
+
+---
+
+## Quickstart
+
+No commitment. Nothing gets upgraded until you say so.
+
+**Linux / macOS**
+```sh
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+./setup.sh
+# open a new shell (or: source ~/.bashrc)
+up --help
+up -n # preview: shows what would run
+up # real run
+```
+
+**Windows** (PowerShell)
+```powershell
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+.\setup.ps1
+# open a new PowerShell window (or: . $PROFILE)
+up --help
+up -n # preview
+up # real run
+```
+
+### What you'll see on the first real run
+
+On a Debian/Ubuntu desktop with Flatpak installed, expect something like:
+
+```text
+ _____ __ __
+| ___| | \/ | eMerger v2.0.0
+| |__ | |\/| | one command for the whole system
+|____| |_| |_|
+Ubuntu 24.04 o x86_64 o 2026-04-16 10:12
+eMerger v2.0.0 o github.com/MasterCruelty/eMerger
+[1/3] apt OK (38 upgraded, 0 removed, 0 new)
+[2/3] flatpak OK (5 refreshed)
+[3/3] fwupd SKIP (--firmware not set)
++---------------------------------------------+
+| eMerger - summary |
+| duration 42s |
+| freed 15 MiB |
+| reboot not required |
+| errors 0 |
++---------------------------------------------+
+```
+
+> **Do not run `sudo up`.** eMerger will ask for `sudo` itself, only for
+> the managers that need it. Running `sudo up` would install user-level
+> packages (e.g. `pip --user`, Homebrew, `npm -g`) into root's home.
+
+---
+
+## What `up` actually does
+
+Each full run performs, in order:
+
+1. Load `config.sh` (or `config.ps1`) and any `--profile`.
+2. Parse CLI flags. CLI always wins over config and profile defaults.
+3. Acquire a global exclusive lock (`/tmp/emerger.lock` on Unix).
+4. Print logo, OS info line and timestamp (skippable with `-nl`/`-ni`).
+5. Warn on low battery and on low free disk space.
+6. Cache `sudo` credentials (Unix) or relaunch elevated (Windows) if any
+ detected manager needs it.
+7. Snapshot installed packages (for the post-run diff).
+8. Run `pre.d` hooks.
+9. For each detected manager: **refresh โ upgrade โ clean**.
+10. Optionally clean user cache / `%TEMP%` and trash / Recycle Bin.
+11. Run `post.d` hooks.
+12. Compute the installed-packages diff.
+13. Print boxed summary + reboot advisory.
+14. Emit desktop notification if the session has a display (or if
+ BurntToast is installed on Windows).
+15. Exit 0 on success, 3 if any manager failed, 4 if a reboot is pending
+ and `--reboot-exit` was passed.
+
+---
+
+## Requirements
+
+**Linux**
+- bash โฅ 3.2, coreutils, git, sudo.
+- Optional: `gum`/`whiptail`, `notify-send`, `curl`, `flock`, `reflector`,
+ `netselect-apt`, `snapper`/`timeshift`/`btrfs-progs`, `fwupdmgr`.
+
+**macOS**
+- bash โฅ 3.2 (the system `/bin/bash` works out of the box; no Homebrew bash needed).
+- Xcode Command Line Tools (for git).
+- Optional: Homebrew, `mas` (`brew install mas`).
+
+**Windows**
+- PowerShell 5.1 (built-in on Win10+) or 7+.
+- Git for Windows (for `up --self-update`).
+- Optional: `winget`, `scoop`, `choco`, `PSWindowsUpdate`
+ (`Install-Module PSWindowsUpdate -Scope CurrentUser`), `BurntToast`
+ (`Install-Module BurntToast`) for toast notifications.
+
+eMerger never installs these for you - it only uses what's there.
+
+---
+
+## Installation
+
+### Install (Linux)
+
+```sh
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+./setup.sh
+```
+
+`setup.sh` does exactly this:
+
+1. Adds `alias up='bash /path/to/eMerger/src/emerger.sh'` to your shell rc
+ (bash, zsh, fish - whichever you use).
+2. Makes `src/emerger.sh` executable.
+3. Installs shell completions under:
+ - `~/.local/share/bash-completion/completions/up`
+ - `~/.zsh/completions/_up`
+ - `~/.config/fish/completions/up.fish`
+4. Creates `~/.config/emerger/{config.sh,ignore.list,hooks/,profiles.d/}`.
+
+After install, **open a new shell** (or `source ~/.bashrc`).
+
+### Install (macOS)
+
+Identical script, same flow:
+
+```sh
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+./setup.sh
+```
+
+macOS specifics handled automatically:
+- The alias is written to `~/.zshrc` first (zsh is the default shell since Catalina).
+- If Homebrew is installed, completions go to
+ `$(brew --prefix)/etc/bash_completion.d/` and
+ `$(brew --prefix)/share/zsh/site-functions/`.
+- `softwareupdate` (native macOS updater) is auto-detected alongside `brew`,
+ `brew --cask`, and `mas`.
+
+### Install (Windows)
+
+```powershell
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+.\setup.ps1
+```
+
+`setup.ps1` does:
+
+1. Sets `ExecutionPolicy` to `RemoteSigned` for `CurrentUser` if it was
+ `Restricted` or `Undefined`. No admin needed for this.
+2. Adds a `function up { & "โฆ\emerger.ps1" @args }` block to your
+ `$PROFILE.CurrentUserAllHosts` (so `up` works from any host: cmd-hosted
+ PS, ISE, Terminalโฆ).
+3. Scaffolds `%APPDATA%\emerger\{config.ps1,hooks\,profiles.d\}`.
+
+After install, **open a new PowerShell window** (or `. $PROFILE`).
+
+> **Important**: `setup.ps1` does **not** require admin. Package manager
+> runs that need admin will trigger a UAC prompt via automatic elevation.
+
+### Manual install
+
+All three platforms: just point an alias/function at the entry point.
+
+```sh
+# bash/zsh/fish - macOS or Linux
+alias up='bash /absolute/path/to/eMerger/src/emerger.sh'
+```
+
+```powershell
+# PowerShell - Windows
+function up { & "C:\path\to\eMerger\src\emerger.ps1" @args }
+```
+
+---
+
+## Uninstallation
+
+```sh
+# Linux / macOS
+./uninstall.sh
+```
+
+```powershell
+# Windows
+.\uninstall.ps1
+```
+
+Removes the shell alias / `up` function, any cronjob, systemd user timer or
+scheduled task. **Keeps** your config and state directories so you don't
+lose history or hooks. Delete those paths manually if you want a clean
+wipe:
+
+```sh
+rm -rf ~/.config/emerger ~/.cache/emerger ~/.local/state/emerger
+```
+
+The repo itself is not removed - `rm -rf` or `Remove-Item` the directory
+when you're done.
+
+---
+
+## Update / self-update
+
+Three equivalent ways:
+
+```sh
+up -up # flag form
+```
+or
+```sh
+up --self-update
+```
+or
+```sh
+./update.sh # Linux / macOS
+.\update.ps1 # Windows
+```
+
+This does a `git pull --ff-only` inside the repo and shows the commit range.
+Refuses non-fast-forward pulls so local changes never silently vanish.
+
+For automatic updates of eMerger itself, put it in `post.d` hook (see
+[Hooks](#hooks)).
+
+---
+
+## User manual
+
+### Running
+
+```sh
+up # Linux / macOS
+```
+```powershell
+up # Windows
+```
+
+Full list of flags: `up --help`.
+
+### Flags, quick reference
+
+Authoritative list: `up --help`. Highlights:
+
+| Flag | Meaning | Platforms |
+|---|---|---|
+| `-n`, `--dry-run` | Preview, don't run | all |
+| `-v`, `--verbose` | Stream output live | all |
+| `-q` / `-qq` / `-qqq` | Quieter | all |
+| `-y`, `--yes` | Assume yes | all |
+| `-i`, `--interactive` | Menu UI | Linux/macOS |
+| `--security` | Security-only | all (where supported) |
+| `--dev` | Include dev toolchains | all |
+| `--firmware` | `fwupdmgr` | Linux |
+| `--parallel` | Run user-space concurrently | Linux/macOS |
+| `--profile NAME` | Load a profile | all |
+| `--snapshot` | snapper/timeshift/btrfs | Linux |
+| `--refresh-mirrors` | re-rank mirrors | Linux |
+| `--resume` | Skip completed managers | Linux/macOS |
+| `--reboot` | Reboot if required | all |
+| `--changed` | Package diff | Linux/macOS |
+| `--changelog PKG` | Upstream changelog | Linux/macOS |
+| `--report FILE` | Export Markdown | Linux/macOS |
+| `--doctor` | Health check | all |
+| `--history` | Recent runs | all |
+| `--errors` / `-err` | Log tail | all |
+| `--no-emoji` | ASCII only | all |
+| `--json` | Machine-readable summary | all |
+| `--reboot-exit` | Exit 4 if reboot is required | all |
+| `--rollback` | Revert last snapshot | Linux |
+| `--download-only` / `--offline` | Prefetch, don't install | Linux/macOS (apt/dnf/pacman/zypper) |
+| `--only LIST` | Keep only these managers | all |
+| `--except LIST` | Skip these managers | all |
+| `--metrics FILE` | Prometheus textfile export | all |
+| `-up` | Self-update | all |
+| `-au` | Install auto-update | all |
+
+### Combining flags
+
+Flags are independent tokens - mix and match as many as you need, in any
+order. Short bundling `-nv` โ `-n -v` is supported for the letters
+`{h V n v q y i w}`. Compound short flags (`-nl`, `-ni`, `-qq`, `-up`,
+`-err`, ...) and long flags pass through unchanged.
+
+```sh
+up -n -v # dry-run + live stream (preview a full run)
+up -y -q --security # unattended security-only, minimal output
+up --dev --parallel -v # dev toolchains + user-space concurrency, verbose
+up --snapshot --reboot -y # snapshot first, reboot at the end if needed
+up --profile server --resume # resume an interrupted headless run
+up -n --dev --firmware # preview a full dev + firmware run, no side effects
+up -qq -y -nl -ni --security # exactly what the scheduled timer does
+up --refresh-mirrors -y -v # re-rank mirrors then upgrade, watch it live
+up --changed --report out.md # show diff and export it in one shot
+```
+
+```powershell
+up -y -q --security # Windows, unattended security-only
+up --dev -v # Windows, include dev toolchains, verbose
+up -n --security # Windows, preview a security-only run
+```
+
+Flags that take a value (`--profile NAME`, `--changelog PKG`,
+`--report FILE`, `--metrics FILE`, `--only LIST`, `--except LIST`) must
+keep their argument adjacent; everything else is position-free. CLI flags
+always win over config file and profile defaults, so you can override a
+profile on the fly:
+
+```sh
+up --profile work --dev # work profile, but force dev toolchains this run
+```
+
+### Interactive mode
+
+```sh
+up -i
+```
+
+Menu via `gum` (pretty), `whiptail` (classic), or plain read-loop.
+Windows does not ship a TUI - use flags directly or a profile.
+
+### Dry-run & verbose
+
+```sh
+up -n # see what would happen (safe, no sudo)
+up -v # stream pkg-manager output live
+up -n -v # both
+```
+
+Example of `-n` output:
+
+```text
+$ up -n
+[dry] sudo apt update
+[dry] sudo apt upgrade -y
+[dry] sudo apt autoremove -y
+[dry] sudo apt clean
+[dry] flatpak update -y
+[dry] flatpak uninstall --unused -y
+```
+
+### Quiet levels
+
+- **default** - full UI with box and spinner
+- `-q` - hide muted/info lines
+- `-qq` - only step titles + one-line summary (great for systemd logs)
+- `-qqq` - no output at all; exit code is the only signal
+
+### Security-only updates
+
+```sh
+up --security -y
+```
+
+- Linux: `apt` (via `unattended-upgrade`), `dnf` (`--security`),
+ `zypper` (`patch --category security`).
+- macOS: `softwareupdate --install --recommended`.
+- Windows: `PSWindowsUpdate` respects KB severity if the module supports it.
+
+Other managers ignore the flag.
+
+### Dev toolchains
+
+Opt-in (every platform):
+
+```sh
+up --dev
+```
+
+Updates:
+
+| Tool | Command |
+|---|---|
+| `rustup` | `rustup self update && rustup update stable` |
+| `cargo` | `cargo install-update -a` |
+| `npm` | `npm update -g` |
+| `pnpm` | `pnpm -g update` |
+| `pip` | user-site upgrades |
+| `gem` | `gem update` |
+
+These never run under `sudo`. Missing toolchains are silently skipped.
+
+### Firmware (Linux)
+
+```sh
+up --firmware
+```
+
+Runs `fwupdmgr refresh && fwupdmgr update -y --no-reboot-check`. Windows
+firmware is handled by vendor tools (Dell Command Update, Lenovo Vantage)
+and is out of scope. macOS firmware is handled by `softwareupdate`.
+
+### Parallel mode
+
+```sh
+up --parallel
+```
+
+User-space managers that don't touch `/` run concurrently (`flatpak`,
+`snap`, `brew`, `mas`, dev tools). System managers stay serial. Windows
+side is currently always serial.
+
+### Snapshots (Linux)
+
+```sh
+up --snapshot
+```
+
+Tries in order: `snapper`, `timeshift`, `btrfs subvolume snapshot`. Windows
+users: enable **System Restore** manually; eMerger doesn't trigger it.
+
+### Mirrors refresh (Linux)
+
+```sh
+up --refresh-mirrors
+```
+
+- Arch: `reflector --latest 20 --sort rate`.
+- Debian/Ubuntu: `netselect-apt`.
+- Fedora: handled by `fastestmirror` plugin automatically; no-op here.
+
+### Reboot handling
+
+After a run, eMerger checks for reboot flags:
+
+- Linux: `/var/run/reboot-required`, `needs-restarting -r`.
+- Windows: registry keys (`CBS RebootPending`, `WindowsUpdate\RebootRequired`,
+ `PendingFileRenameOperations`).
+
+To reboot on demand:
+
+```sh
+up --reboot # reboots if required, no-op otherwise
+```
+
+### Resume after interruption
+
+If you kill a run mid-way:
+
+```sh
+up --resume
+```
+
+skips every manager that successfully completed in the last interrupted run.
+State lives in `~/.local/state/emerger/resume`. Linux/macOS only.
+
+### Package diff & changelog
+
+Every run records installed packages before/after. View it:
+
+```sh
+up --changed
+```
+
+Example output:
+
+```text
+$ up --changed
+~ firefox 123.0.1 -> 124.0
+~ libc6 2.39-0 -> 2.39-1
++ flatpak-xdg-utils 1.0.6
+- old-package 2.1
+```
+
+Legend: `+` added, `-` removed, `~` upgraded. Linux/macOS.
+
+Read a single package's upstream changelog:
+
+```sh
+up --changelog firefox
+```
+
+Dispatches to `apt changelog`, `dnf changelog`/`updateinfo`, `pacman -Qi`,
+or `brew log`.
+
+### Reports
+
+```sh
+up --report report.md
+```
+
+Markdown export of the last run: JSON summary, managers, reboot advisory,
+full package diff as a table.
+
+### History & errors
+
+```sh
+up --history # last 10 runs
+up --errors # tail of ERROR lines from the log
+```
+
+### Doctor
+
+```sh
+up --doctor
+```
+
+Audits:
+- shell / PowerShell version
+- sudo cache / admin status
+- disk space
+- network reachability
+- state dir writability
+- per-manager native health (`dpkg --audit`, `pacman -Dk`, `brew doctor`, โฆ)
+- pending reboot flag
+- (Windows) ExecutionPolicy
+
+Exits non-zero if issues are found. **Always attach `up --doctor` output
+when reporting a bug.**
+
+---
+
+## Configuration
+
+### config file
+
+**Linux / macOS** - `~/.config/emerger/config.sh` (sourced before arg parsing):
+
+```sh
+# Defaults
+ARG_DEV=1 # always include dev toolchains
+ARG_WEATHER=1 # always show weather
+ARG_PARALLEL=1 # user-space managers in parallel
+
+# Thresholds
+DISK_MIN_FREE_MB=2048 # require >= 2 GB on /
+RETRY_MAX=3 # transient-error retries
+
+# Scheduling
+QUIET_HOURS="23:00-07:00" # skip scheduled runs inside this window
+```
+
+**Windows** - `%APPDATA%\emerger\config.ps1` (dot-sourced before arg parsing):
+
+```powershell
+$script:ArgsGlobal.Dev = $true
+$script:ArgsGlobal.Security = $true
+$script:ArgsGlobal.NoTrash = $true
+```
+
+### All config variables
+
+| Variable | Default | Meaning |
+|---|---|---|
+| `ARG_DEV` | 0 | include dev toolchains by default |
+| `ARG_FIRMWARE` | 0 | include `fwupdmgr` by default |
+| `ARG_NO_FIRMWARE` | 0 | force-skip firmware |
+| `ARG_SECURITY` | 0 | security-only by default |
+| `ARG_YES` | 0 | assume yes by default |
+| `ARG_PARALLEL` | 0 | parallel user-space by default |
+| `ARG_WEATHER` | 0 | show weather widget |
+| `ARG_NO_EMOJI` | 0 | force ASCII glyphs |
+| `ARG_NO_CACHE` | 0 | skip user cache cleaning |
+| `ARG_NO_TRASH` | 0 | skip trash cleaning |
+| `ARG_NO_LOGO` | 0 | hide logo |
+| `ARG_NO_INFO` | 0 | hide system info line |
+| `QUIET_LEVEL` | 0 | 0=full UI, 1=`-q`, 2=`-qq`, 3=`-qqq` |
+| `DISK_MIN_FREE_MB` | 1024 | abort/warn below this many MiB |
+| `BATTERY_MIN_PCT` | 20 | warn below this battery percent |
+| `RETRY_MAX` | 2 | transient-error retries |
+| `RETRY_DELAY` | 3 | seconds between retries |
+| `QUIET_HOURS` | (unset) | `"HH:MM-HH:MM"` - skip scheduled runs inside window |
+| `EMERGER_CACHE_TTL` | 86400 | detection cache TTL in seconds, 0 disables |
+
+> CLI flags always win. `ARG_SECURITY=1` in `config.sh` plus `up --dev` on
+> the CLI will run security updates **and** dev toolchains.
+
+### Profiles
+
+Profiles are config snippets scoped to a name.
+
+```sh
+up --profile work
+up --list-profiles
+```
+
+Shipped defaults in `share/profiles/`:
+
+| Profile | Meant for |
+|---|---|
+| `work` | laptop at work - security, unattended, no cache/trash |
+| `home` | desktop at home - everything, dev toolchains, parallel |
+| `server` | headless - `-qq`, security, no prompts |
+| `safe` | pre-presentation - security only, no big downloads |
+
+Each platform looks for its own extension:
+
+- Unix โ `share/profiles/.sh`
+- Windows โ `share/profiles/.ps1`
+
+User profiles go in `~/.config/emerger/profiles.d/` (Unix) or
+`%APPDATA%\emerger\profiles.d\` (Windows) and shadow the shipped ones.
+
+Example custom profile (`~/.config/emerger/profiles.d/train.sh`):
+
+```sh
+# description: on a train, prefetch only, keep the fans quiet
+ARG_DOWNLOAD_ONLY=1
+ARG_YES=1
+ARG_QUIET=1
+QUIET_LEVEL=2
+ARG_NO_TRASH=1
+ARG_NO_CACHE=1
+```
+
+Then `up --profile train` does a silent prefetch.
+
+### Hooks
+
+Drop executable scripts in `hooks/pre.d/` (before updates) or
+`hooks/post.d/` (after). They run alphabetically. A failing hook emits a
+warning but never aborts the run.
+
+- Unix: `*.sh`, run under bash.
+- Windows: `*.ps1`, dot-sourced under PowerShell.
+
+**Example 1 - backup dotfiles before every run:**
+
+```sh
+# ~/.config/emerger/hooks/pre.d/10-backup-dotfiles.sh
+#!/usr/bin/env bash
+set -e
+rsync -a --delete ~/.config/ ~/backups/dotfiles/
+```
+
+**Example 2 - Slack notification after every run:**
+
+```sh
+# ~/.config/emerger/hooks/post.d/99-slack.sh
+#!/usr/bin/env bash
+state=~/.local/state/emerger
+payload=$(tail -1 "$state/history.jsonl")
+curl -sS -X POST -H 'Content-Type: application/json' \
+ -d "{\"text\":\"eMerger: $payload\"}" "$SLACK_WEBHOOK_URL"
+```
+
+**Example 3 - copy last log to clipboard (Windows):**
+
+```powershell
+# %APPDATA%\emerger\hooks\post.d\10-log-to-clipboard.ps1
+$log = Join-Path $env:LOCALAPPDATA 'emerger\state\emerger.log'
+Get-Content $log -Tail 40 | Set-Clipboard
+```
+
+**Example 4 - export Prometheus metrics automatically:**
+
+```sh
+# ~/.config/emerger/hooks/post.d/90-prom.sh
+#!/usr/bin/env bash
+up --metrics /var/lib/node_exporter/textfile_collector/emerger.prom
+```
+
+> Hooks run with the privileges that invoked `up`. If you need root-owned
+> side effects, write them in `post.d` and guard with `sudo -n` or a
+> targeted sudoers entry.
+
+### Ignore list (Linux)
+
+`~/.config/emerger/ignore.list` - one package per line, `#` comments ok.
+Honored natively by **pacman** (`--ignore=`). For others it is
+**advisory** - you still need to hold them via:
+
+- apt: `sudo apt-mark hold `
+- dnf: `sudo dnf versionlock add `
+- zypper: `sudo zypper al `
+
+### Quiet hours
+
+Set `QUIET_HOURS="HH:MM-HH:MM"` in `config.sh`. When a scheduled run starts
+inside that window **and** `-y` is set (i.e. from the timer), eMerger exits
+immediately. Interactive runs always proceed. Windows across midnight is
+supported (e.g. `"23:00-07:00"`). Linux/macOS.
+
+### Manager plugins
+
+Drop a bash script in `~/.config/emerger/managers.d/.sh` to add support
+for a package manager without touching the repo. A minimal plugin:
+
+```sh
+PM_PLUGIN_SLUG=mytool
+
+pm_mytool_detect() { command -v mytool >/dev/null 2>&1; }
+pm_mytool_needs_sudo() { return 1; } # optional, default: no sudo
+pm_mytool_parallel() { return 0; } # optional, default: serial
+pm_mytool_dev() { return 1; } # optional, default: not gated by --dev
+pm_mytool_icon() { printf '๐'; } # optional
+
+pm_mytool_run() {
+ run_cmd "mytool refresh" mytool refresh || return 1
+ run_cmd "mytool upgrade" mytool upgrade -y || return 1
+}
+```
+
+A complete, copy-pasteable template lives in
+[`share/plugins/example.sh`](share/plugins/example.sh).
+
+Plugins are registered at the same level as native managers: they honour
+`--only`, `--except`, `--parallel`, `--dev`, the detection cache, hooks and
+the summary. They are invoked from inside `pkg_run`, so `run_cmd` automatically
+gives them `--dry-run`, retry, logging and live-log handling for free.
+
+The detection cache is keyed by manager slug and lives at
+`~/.cache/emerger/detected`. TTL defaults to 1 day; override via
+`EMERGER_CACHE_TTL=` in `config.sh` (0 disables caching). After
+installing or removing a package manager, run `up -rc` to clear the cache.
+
+Linux/macOS only. Windows plugins are not yet supported.
+
+---
+
+## Integration
+
+### JSON output
+
+```sh
+up --json
+```
+
+Emits a single-line JSON object on stdout. The logo, info line and summary
+box are all suppressed, so the output is safe to pipe into `jq` or consume
+from a CI job:
+
+```json
+{"ts":"2026-04-14T07:24:31Z","duration":42,"freed_kb":15360,
+ "errors":0,"reboot":0,
+ "managers":[{"name":"apt","result":"ok"},{"name":"flatpak","result":"ok"}]}
+```
+
+Every run also appends one such line to
+`~/.local/state/emerger/history.jsonl` (one JSON per line). Fields:
+
+| Field | Meaning |
+|---|---|
+| `ts` | ISO 8601 UTC timestamp of the run start |
+| `duration` | total wall-clock seconds |
+| `freed_kb` | cache and trash bytes freed, in KiB |
+| `errors` | number of managers that returned non-zero |
+| `reboot` | 1 if a reboot is required, else 0 |
+| `managers` | array of `{name, result}` entries |
+
+### Prometheus metrics
+
+```sh
+up --metrics /var/lib/node_exporter/textfile_collector/emerger.prom
+```
+
+Reads the most recent entry from `history.jsonl` and renders a Prometheus
+textfile-collector snapshot. Exported gauges:
+
+- `emerger_last_run_timestamp_seconds`
+- `emerger_last_run_duration_seconds`
+- `emerger_last_run_freed_bytes`
+- `emerger_last_run_errors`
+- `emerger_reboot_required`
+- `emerger_manager_ok{manager="..."}` (one per manager from the last run)
+
+Does not trigger a run - invoke it from a `post.d` hook or from your timer
+after `up` completes.
+
+### Reboot exit code
+
+By default eMerger always exits 0 on success even when a reboot is pending
+(the summary box prints `REBOOT RECOMMENDED`). Pass `--reboot-exit` to turn
+that into exit code **4** instead, so an orchestrator can react:
+
+```sh
+up -y --reboot-exit
+rc=$?
+case $rc in
+ 0) ;; # done, no reboot needed
+ 3) notify-send "eMerger: some managers failed" ;;
+ 4) systemctl reboot ;; # clean, reboot required
+esac
+```
+
+### Download-only / offline
+
+```sh
+up --download-only -y # or --offline
+```
+
+Refreshes indexes and **downloads** the pending upgrade set but does not
+install it. Supported natively on `pacman` (`-Syuw`), `apt`/`apt-get`
+(`--download-only`), `dnf` (`--downloadonly`) and `zypper`
+(`update --download-only`). Other managers ignore the flag.
+
+Typical use cases:
+
+- laptop on a slow/metered connection at a cafรฉ: prefetch while online,
+ install later at the office
+- servers in a maintenance window: prestage packages, then flip to
+ install-only when the change ticket opens
+- pre-flight for `--snapshot`: confirm the whole update set is downloaded
+ before taking a snapshot
+
+### Manager filtering
+
+```sh
+up --only apt,flatpak # only these managers (comma-separated)
+up --except snap,fwupd # everything that would run, minus these
+```
+
+The filters are applied after detection and after `--dev`/`--firmware`
+gating, so:
+
+- `--only X` with `X` not detected is a no-op (nothing runs).
+- `--except` wins over `--only` when both mention the same name.
+
+Compose with profiles (`up --profile work --only apt`) to restrict a
+profile on the fly.
+
+### Rollback
+
+```sh
+up --rollback
+```
+
+Reverts to the most recent eMerger-created snapshot. Dispatches to:
+
+- **snapper**: native `snapper -c root rollback `. Grep-finds the last
+ snapshot whose description starts with `eMerger pre-update`. A reboot is
+ required to apply the rollback (snapper semantics, not ours).
+- **timeshift**: hands off to `timeshift --restore` (interactive by design;
+ we don't pass `--yes` - rollback is too destructive for that).
+- **raw btrfs**: refuses to swap subvolumes automatically, prints the path
+ of the latest snapshot under `/.snapshots/emerger/` so you can do it
+ manually.
+
+Combine with `--snapshot` for a safe update cycle:
+
+```sh
+up --snapshot -y || up --rollback
+```
+
+### Short flag bundling
+
+Single-letter short flags can be bundled:
+
+```sh
+up -nv # == up -n -v
+up -ynv # == up -y -n -v
+up -qv # == up -q -v
+```
+
+Only flags whose letters are all in the set `{h V n v q y i w}` bundle.
+Compound short flags (`-nl`, `-ni`, `-nc`, `-nt`, `-qq`, `-qqq`, `-up`,
+`-au`, `-err`, `-rc`) and long flags (`--foo`) pass through unchanged.
+
+---
+
+## Auto-update (unattended)
+
+```sh
+up -au # Linux / macOS
+```
+```powershell
+up -au # Windows
+```
+
+- **Linux**: systemd user timer (preferred) at
+ `~/.config/systemd/user/emerger.{service,timer}`; cron fallback.
+- **macOS**: cron fallback (`crontab -l`), or use `launchd` manually.
+- **Windows**: `Register-ScheduledTask -TaskName eMerger`. Weekly, Sunday
+ 10:00, ยฑ1h randomized delay.
+
+The scheduled run always uses `-y -q -nl -ni`.
+
+Manage:
+
+```sh
+# Linux
+systemctl --user status emerger.timer
+systemctl --user disable emerger.timer
+
+# Windows
+Get-ScheduledTask eMerger
+Unregister-ScheduledTask eMerger
+```
+
+To avoid night-time runs, pair with `QUIET_HOURS` in `config.sh`.
+
+> On Linux, systemd **user** timers do not fire unless a login session is
+> open for the user. Run `loginctl enable-linger $USER` once (as root) to
+> make them fire in the background.
+
+---
+
+## Cookbook / Recipes
+
+Concrete, copy-pasteable snippets for common situations.
+
+### Daily driver (desktop)
+
+```sh
+up -v
+```
+
+Simple. Live output, everything updated, cache and trash wiped at the
+end. Add `--firmware` once a month on Linux.
+
+### Unattended server
+
+One-off setup:
+
+```sh
+cat > ~/.config/emerger/config.sh <<'EOF'
+ARG_SECURITY=1
+ARG_YES=1
+ARG_NO_CACHE=1
+ARG_NO_TRASH=1
+QUIET_HOURS="08:00-18:00"
+EOF
+up -au
+```
+
+Pair with a Prometheus hook for dashboards:
+
+```sh
+cat > ~/.config/emerger/hooks/post.d/90-prom.sh <<'EOF'
+#!/usr/bin/env bash
+up --metrics /var/lib/node_exporter/textfile_collector/emerger.prom
+EOF
+chmod +x ~/.config/emerger/hooks/post.d/90-prom.sh
+```
+
+### Developer workstation
+
+```sh
+up --dev --firmware --parallel -v
+# or, as a profile:
+up --profile home
+```
+
+### Pre-demo machine
+
+The day before a presentation: security patches, no big downloads, no
+reboot.
+
+```sh
+up --profile safe
+```
+
+### Metered / mobile connection
+
+Prefetch when on good Wi-Fi:
+
+```sh
+up --download-only -y
+```
+
+Install later, even without network (the cache is already warm):
+
+```sh
+up -y
+```
+
+### CI runner / container image
+
+```sh
+up --only apt -y -qq --reboot-exit
+rc=$?
+if [[ $rc -eq 4 ]]; then echo "reboot required"; exit 0; fi
+exit $rc
+```
+
+### Maintenance window with rollback guard
+
+Snapshot, update, rollback on failure, reboot on success:
+
+```sh
+set -e
+up --snapshot -y || { up --rollback; exit 1; }
+up -y --reboot-exit
+rc=$?
+if [[ $rc -eq 4 ]]; then systemctl reboot; fi
+```
+
+### macOS with Homebrew only
+
+```sh
+up --only brew,mas -v
+```
+
+### Windows without Chocolatey
+
+```powershell
+up --except choco -v
+```
+
+### Audit a single package
+
+```sh
+up -n | grep -i firefox # what would happen to it
+up --changelog firefox # what changed upstream
+```
+
+### Pin a package (Linux)
+
+Add to `~/.config/emerger/ignore.list`:
+
+```text
+nvidia-driver-535
+```
+
+Then tell `apt` explicitly:
+
+```sh
+sudo apt-mark hold nvidia-driver-535
+```
+
+(pacman respects the file natively.)
+
+---
+
+## Safety & security
+
+- eMerger is a client-side tool. No network listener, no daemon, no
+ persistent background process.
+- It reads and writes only three locations: `~/.config/emerger/`,
+ `~/.cache/emerger/`, `~/.local/state/emerger/`, plus the global lock at
+ `/tmp/emerger.lock` on Unix.
+- `sudo` credentials are cached by `sudo` itself, not by eMerger. When
+ the run ends, the cached credentials expire on the usual `sudo`
+ schedule (5 minutes by default).
+- On Windows, UAC elevation happens **only** when a detected manager
+ actually needs admin. The elevation is logged.
+- Hooks and plugins run as the invoking user. They can do anything the
+ user can do - only install hooks from sources you trust.
+- The only network calls eMerger itself makes are (a) the optional
+ weather widget via `wttr.in`, (b) `git pull` during `--self-update`,
+ (c) `fwupdmgr refresh` when `--firmware` is active. Everything else is
+ delegated to the package manager you asked for.
+- **No telemetry.** `history.jsonl` stays on your machine unless a hook
+ you wrote ships it elsewhere.
+
+---
+
+## Files & paths
+
+### Linux / macOS
+
+| Path | Purpose |
+|---|---|
+| `~/.config/emerger/config.sh` | User defaults |
+| `~/.config/emerger/profiles.d/` | User profiles |
+| `~/.config/emerger/hooks/pre.d/`, `post.d/` | Hooks |
+| `~/.config/emerger/ignore.list` | Ignore list (pacman native) |
+| `~/.config/emerger/managers.d/*.sh` | User-defined manager plugins |
+| `~/.cache/emerger/detected` | Detection cache (TTL: `EMERGER_CACHE_TTL`, default 86400s) |
+| `~/.local/state/emerger/emerger.log` | Log (rotated at 2000 lines) |
+| `~/.local/state/emerger/history.jsonl` | One JSON per run |
+| `~/.local/state/emerger/pkgs.before` | Pre-run package snapshot |
+| `~/.local/state/emerger/pkgs.after` | Post-run package snapshot |
+| `~/.local/state/emerger/pkgs.diff` | Last-run package diff |
+| `~/.local/state/emerger/resume` | Resume cursor |
+| `/tmp/emerger.lock` | Global lock (`flock`) |
+
+### Windows
+
+| Path | Purpose |
+|---|---|
+| `%APPDATA%\emerger\config.ps1` | User defaults |
+| `%APPDATA%\emerger\profiles.d\` | User profiles |
+| `%APPDATA%\emerger\hooks\pre.d\`, `post.d\` | Hooks |
+| `%LOCALAPPDATA%\emerger\cache\` | Detection cache |
+| `%LOCALAPPDATA%\emerger\state\emerger.log` | Log |
+| `%LOCALAPPDATA%\emerger\state\history.jsonl` | Run history |
+
+---
+
+## Supported package managers
+
+**Linux - system (need sudo):**
+`pacman`, `apt`/`apt-get`, `dnf`, `yum`, `zypper`, `xbps`, `apk`, `eopkg`,
+`emerge`, `nixos-rebuild`, `fwupdmgr`, `snap`.
+
+**Linux - AUR (no sudo):** `yay`, `paru`.
+
+**Linux - user-space:** `flatpak`, `nix-env`.
+
+**macOS:** `softwareupdate` (native), `brew`, `brew --cask`, `mas`.
+
+**Windows:** `winget`, `scoop`, `choco`, `PSWindowsUpdate`, `wsl --update`.
+
+**Dev toolchains** (all platforms, opt-in with `--dev`): `rustup`,
+`cargo install-update`, `npm`, `pnpm`, `pip` (user), `gem`.
+
+Want another one? Add a case branch to
+[`src/lib/packages.sh`](src/lib/packages.sh) (Unix) or
+[`src/pslib/Packages.ps1`](src/pslib/Packages.ps1) (Windows) - they're
+simple table-driven dispatchers. Or drop a [plugin](#manager-plugins).
+
+---
+
+## Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | success |
+| 1 | runtime failure (sudo, lock, disk, interrupted) |
+| 2 | argument parsing error |
+| 3 | one or more package managers returned non-zero |
+| 4 | reboot required (only emitted with `--reboot-exit`) |
+
+Useful for CI / cron wrappers:
+
+```sh
+up -y -q || case $? in
+ 3) notify-send "eMerger: some managers failed" ;;
+ *) logger -t eMerger "fatal $?" ;;
+esac
+```
+
+```powershell
+up -y -q
+if ($LASTEXITCODE -eq 3) { Write-Warning "Some managers failed" }
+```
+
+---
+
+## Troubleshooting
+
+**"Another eMerger run is in progress"** - stale `flock` on
+`/tmp/emerger.lock`. Check `ps` for stragglers then remove it.
+
+**Emoji renders as boxes** - `--no-emoji`, or set `LANG` / Windows Terminal
+font to a Unicode-capable one.
+
+**Spinner disappears on terminal resize** - harmless; the live-log width
+recomputes every 120ms.
+
+**`up --self-update` aborts with "non fast-forward"** - you have local
+commits on top of `main`. Rebase or reset manually; eMerger refuses to
+clobber them.
+
+**`notify-send` (Linux) doesn't appear** - no `DISPLAY` /
+`WAYLAND_DISPLAY` in the environment (typical for cron). Use systemd user
+timer instead; it inherits the session.
+
+**A manager fails with exit code 3** - run `up --errors` to tail the log,
+then `up --doctor` to inspect the environment. Most failures come from a
+manager's own health state (for example `dpkg` interrupted); run the
+native repair command (`sudo dpkg --configure -a`, `sudo pacman -Dk`)
+before retrying.
+
+**Nothing happens on scheduled runs** - check
+`systemctl --user status emerger.timer` or the Windows Task Scheduler
+entry. On Linux, user timers don't fire without an open session unless
+you run `loginctl enable-linger $USER`.
+
+**(Windows) "up : The term 'up' is not recognized"** - your PowerShell
+profile didn't load. Run `. $PROFILE` or open a new window. If still
+missing, re-run `.\setup.ps1`.
+
+**(Windows) "cannot be loaded because running scripts is disabled"** -
+`ExecutionPolicy` is `Restricted`. Run as user:
+```powershell
+Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
+```
+
+**(Windows) Elevation fails silently** - you canceled the UAC prompt. The
+script logs `relaunching elevated` then exits; the elevated window does
+the actual work.
+
+**Pacman keeps asking about ignored packages** - `ignore.list` is passed
+as `--ignore=`; pacman still prints the warning line. Upstream behavior.
+
+**"command not found: gum" or "whiptail"** - the interactive menu (`-i`)
+falls back to a plain read loop when neither is installed. Install
+[gum](https://github.com/charmbracelet/gum) for a nicer UI or ignore the
+warning.
+
+---
+
+## FAQ
+
+**Is eMerger safe for production servers?**
+Yes, but use it in security-only, unattended mode: `up --profile server`.
+Pair with `--reboot-exit` so your orchestrator decides when to reboot.
+
+**Does `up` install packages I don't already have?**
+No. `up` only invokes managers that are already installed and only asks
+them to upgrade what is already installed. No new software is added
+unless a package upgrade pulls in a new dependency.
+
+**Does `up` reboot my machine?**
+Only with `--reboot`. Without it, the summary prints a reboot advisory
+and the run exits normally.
+
+**Can I use `up` without `sudo`?**
+Yes, in user-only mode: `up --only flatpak,brew,npm,pip,cargo` (adjust to
+what you have). User-level managers never need elevation.
+
+**Can I use `up` inside a Docker image build?**
+Yes. Use `up --only apt -y -qq` (or the relevant manager) and ignore the
+reboot advisory. Avoid `--firmware`, `--snapshot` and interactive flags.
+
+**What happens if two `up` runs start at the same time?**
+The second one aborts with exit code 1 ("Another eMerger run is in
+progress"). The lock is a plain `flock` on `/tmp/emerger.lock`.
+
+**How do I completely remove eMerger?**
+Run the uninstaller, then:
+```sh
+rm -rf ~/.config/emerger ~/.cache/emerger ~/.local/state/emerger
+rm -rf /path/to/eMerger # the repo itself
+```
+
+**Where is my data?**
+All local, all in the paths listed in [Files & paths](#files--paths).
+Nothing leaves your machine unless a hook you wrote ships it.
+
+**Can I contribute a new package manager?**
+Absolutely - see [Development](#development). Most contributions are a
+20-line dispatch branch plus a test case.
+
+**Why is the terminal flashing during `--parallel -v`?**
+Each user-space manager streams its own output concurrently. Drop `-v`
+or narrow the parallel set with `--only` if you want a calm terminal.
+
+---
+
+## Glossary
+
+- **Dry-run**: a simulation. The tool prints the commands it would run
+ without actually running them.
+- **Hook**: a user script that runs before (`pre.d`) or after (`post.d`)
+ the update flow.
+- **Manager (package manager)**: software like `apt`, `pacman`, `brew`,
+ `winget` that installs, upgrades and removes programs on your machine.
+- **Parallel mode**: concurrent execution of user-space managers.
+- **Plugin**: a user-provided manager definition. Registers a new
+ manager without modifying the repository.
+- **Profile**: a named bundle of default flags, loaded via
+ `--profile NAME`.
+- **Resume cursor**: a file that records which managers completed
+ successfully, consumed by `--resume`.
+- **Snapshot**: a read-only filesystem checkpoint taken before the
+ upgrade. Linux only (snapper/timeshift/btrfs).
+- **TUI**: Terminal User Interface. The interactive menu (`-i`) on Unix.
+- **UAC**: User Account Control, the Windows elevation prompt. eMerger
+ relaunches itself elevated when needed.
+
+---
+
+## Development
+
+### Repo layout
+
+```text
+eMerger/
+โโโ src/
+โ โโโ emerger.sh # Unix entry (Linux + macOS)
+โ โโโ emerger.ps1 # Windows entry
+โ โโโ lib/ # bash libs
+โ โโโ pslib/ # PowerShell libs
+โ โโโ logo/
+โโโ share/profiles/ # shipped profiles (*.sh + *.ps1)
+โโโ share/plugins/ # example plugins
+โโโ completions/ # bash/zsh/fish completions
+โโโ tests/ # bats tests
+โโโ man/up.1 # man page
+โโโ doc/ # printable documentation
+โโโ setup.sh setup.ps1
+โโโ uninstall.sh uninstall.ps1
+โโโ update.sh update.ps1
+โโโ VERSION
+โโโ .github/workflows/ci.yml
+```
+
+### Unix lib modules (`src/lib/`)
+
+| File | Role |
+|---|---|
+| `ui.sh` | Colors, glyphs, spinner, box, live-log monitor |
+| `log.sh` | Structured logging (rotated) |
+| `sys.sh` | OS/shell/battery/disk detection |
+| `run.sh` | Command runner (dry-run, retry, progress) |
+| `args.sh` | Argument parser |
+| `packages.sh` | Per-manager dispatcher |
+| `clean.sh` | Cache and trash cleaners |
+| `hooks.sh` | User hook runner |
+| `update.sh` | Self-update + cron/timer setup |
+| `notify.sh` | Desktop notifications |
+| `summary.sh` | Final banner, history persistence |
+| `tui.sh` | Interactive menu |
+| `lock.sh` | Global `flock` |
+| `retry.sh` | Retry on transient failures |
+| `reboot.sh` | Reboot-required detection |
+| `diff.sh` | Package snapshots + diff |
+| `disk.sh` | Disk-space precheck |
+| `snapshot.sh` | snapper/timeshift/btrfs |
+| `mirrors.sh` | Mirror rank/refresh |
+| `resume.sh` | Resume cursor |
+| `doctor.sh` | `--doctor` |
+| `changelog.sh` | `--changelog PKG` |
+| `report.sh` | Markdown export |
+| `wizard.sh` | First-run wizard |
+| `profiles.sh` | Profile loader |
+| `progress.sh` | Output summary + highlight |
+| `estimate.sh` | Step ETA from history |
+| `ignore.sh` | Ignore list loader |
+| `plugins.sh` | User plugin loader |
+| `metrics.sh` | Prometheus textfile export |
+
+### PowerShell lib modules (`src/pslib/`)
+
+| File | Role |
+|---|---|
+| `UI.ps1` | Colors, glyphs, box, step |
+| `Log.ps1` | Structured logging |
+| `Sys.ps1` | OS, admin, battery, disk, UAC elevation |
+| `Args.ps1` | Arg parser (shift + regex) |
+| `Packages.ps1` | Manager dispatcher + `Run-Cmd` |
+| `Clean.ps1` | `%TEMP%` and Recycle Bin |
+| `Hooks.ps1` | `hooks\{pre,post}.d\*.ps1` |
+| `Update.ps1` | `git pull` self-update + Task Scheduler |
+| `Notify.ps1` | BurntToast (optional) |
+| `Summary.ps1` | Final box, history, reboot detection |
+| `Doctor.ps1` | `--doctor` |
+| `Profiles.ps1` | Profile loader |
+| `Help.txt` | Help text |
+
+### Running tests
+
+```sh
+sudo apt-get install bats shellcheck
+bats tests/
+shellcheck -S error src/emerger.sh src/lib/*.sh setup.sh uninstall.sh update.sh
+```
+
+Both run on push via `.github/workflows/ci.yml`.
+
+### Adding a package manager
+
+**Unix** - edit [`src/lib/packages.sh`](src/lib/packages.sh):
+1. Add its name to `PKG_MANAGERS`.
+2. Add a branch in `_pkg_detect_raw`.
+3. Add a branch in `pkg_run` with `run_cmd` calls.
+4. If it doesn't need sudo, exclude it from `pkg_needs_sudo`.
+5. Optional: emoji in `pkg_icon`, output parser in `progress.sh`.
+
+**Windows** - edit [`src/pslib/Packages.ps1`](src/pslib/Packages.ps1):
+1. Add its name to `$script:PKG_MANAGERS` (or `PKG_DEV`).
+2. Add a `Pkg-Detect` case.
+3. Add a `Pkg-Run` case with `Run-Cmd` calls.
+4. If it needs admin, add it to `Pkg-Need-Admin`.
+
+### Contributing
+
+See [CONTRIBUTING.md](./CONTRIBUTING.md). Open issues and PRs against
+`dev`. Include `up --doctor` output and the relevant chunk of the log when
+reporting bugs.
+
+---
+
+## License
+
+See [LICENSE](./LICENSE).
+
+## Credits
+
+Weather line via [wttr.in](https://github.com/chubin/wttr.in).
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..227cea2
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+2.0.0
diff --git a/completions/_up b/completions/_up
new file mode 100644
index 0000000..6cf8ad6
--- /dev/null
+++ b/completions/_up
@@ -0,0 +1,74 @@
+#compdef up
+# zsh completion for eMerger
+
+_up() {
+ local -a opts
+ opts=(
+ '-h[show help]'
+ '--help[show help]'
+ '-V[show version]'
+ '--version[show version]'
+ '-i[interactive menu]'
+ '--interactive[interactive menu]'
+ '--doctor[environment health check]'
+ '-n[dry-run]'
+ '--dry-run[dry-run]'
+ '-v[verbose]'
+ '--verbose[verbose]'
+ '-q[quiet (repeat for more)]'
+ '-qq[quieter]'
+ '-qqq[quietest]'
+ '--quiet[quiet]'
+ '-y[assume yes]'
+ '--yes[assume yes]'
+ '--security[security only]'
+ '--firmware[include firmware]'
+ '--no-firmware[skip firmware]'
+ '--dev[include dev toolchains]'
+ '--parallel[parallel user-space managers]'
+ '--no-emoji[ASCII glyphs only]'
+ '--profile[load named profile]:profile:_up_profiles'
+ '--list-profiles[list profiles]'
+ '--snapshot[pre-update snapshot]'
+ '--refresh-mirrors[refresh mirrors]'
+ '--resume[skip completed managers]'
+ '--reboot[reboot if required]'
+ '-nl[no logo]'
+ '--no-logo[no logo]'
+ '-ni[no system info]'
+ '--no-info[no system info]'
+ '-nc[no cache clean]'
+ '--no-cache[no cache clean]'
+ '-nt[no trash clean]'
+ '--no-trash[no trash clean]'
+ '-w[weather]'
+ '--weather[weather]'
+ '--changed[show package diff]'
+ '--changelog[show changelog]:package'
+ '--history[run history]'
+ '--report[export markdown]:file:_files'
+ '-err[show errors]'
+ '--errors[show errors]'
+ '-up[self-update]'
+ '--self-update[self-update]'
+ '-au[install auto-update]'
+ '--auto-update[install auto-update]'
+ '-rc[rebuild cache]'
+ '--rebuild-cache[rebuild cache]'
+ )
+ _arguments -s $opts
+}
+
+_up_profiles() {
+ local -a p
+ local dir
+ for dir in "$HOME/.config/emerger/profiles.d" "${0:A:h}/../share/profiles"; do
+ [[ -d $dir ]] || continue
+ for f in "$dir"/*.sh; do
+ [[ -f $f ]] && p+=(${f:t:r})
+ done
+ done
+ _values profile $p
+}
+
+compdef _up up
diff --git a/completions/up.bash b/completions/up.bash
new file mode 100644
index 0000000..0fe1166
--- /dev/null
+++ b/completions/up.bash
@@ -0,0 +1,30 @@
+# bash completion for eMerger
+_up_completion() {
+ local cur prev opts profiles
+ cur="${COMP_WORDS[COMP_CWORD]}"
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
+
+ case "$prev" in
+ --profile)
+ profiles=$(for d in "$HOME/.config/emerger/profiles.d" "$(dirname "$(command -v up 2>/dev/null || echo)")/../share/profiles"; do
+ [ -d "$d" ] && for f in "$d"/*.sh; do [ -f "$f" ] && basename "$f" .sh; done
+ done | sort -u)
+ COMPREPLY=( $(compgen -W "$profiles" -- "$cur") )
+ return 0
+ ;;
+ --report)
+ COMPREPLY=( $(compgen -f -- "$cur") )
+ return 0
+ ;;
+ esac
+
+ opts="-h --help -V --version -i --interactive --doctor
+ -n --dry-run -v --verbose -q -qq -qqq --quiet -y --yes
+ --security --firmware --no-firmware --dev --parallel --no-emoji
+ --profile --list-profiles --snapshot --refresh-mirrors --resume --reboot
+ -nl --no-logo -ni --no-info -nc --no-cache -nt --no-trash -w --weather
+ --changed --changelog --history --report -err --errors
+ -up --self-update -au --auto-update -rc --rebuild-cache"
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
+}
+complete -F _up_completion up
diff --git a/completions/up.fish b/completions/up.fish
new file mode 100644
index 0000000..3c8261e
--- /dev/null
+++ b/completions/up.fish
@@ -0,0 +1,38 @@
+# fish completion for eMerger
+function __up_profiles
+ for d in ~/.config/emerger/profiles.d (status dirname)/../share/profiles
+ test -d $d; and for f in $d/*.sh
+ basename $f .sh
+ end
+ end | sort -u
+end
+
+complete -c up -s h -l help -d 'show help'
+complete -c up -s V -l version -d 'show version'
+complete -c up -s i -l interactive -d 'interactive menu'
+complete -c up -l doctor -d 'environment health check'
+complete -c up -s n -l dry-run -d 'dry-run'
+complete -c up -s v -l verbose -d 'verbose output'
+complete -c up -s q -l quiet -d 'quieter output'
+complete -c up -s y -l yes -d 'assume yes'
+complete -c up -l security -d 'security updates only'
+complete -c up -l firmware -d 'include firmware'
+complete -c up -l no-firmware -d 'skip firmware'
+complete -c up -l dev -d 'include dev toolchains'
+complete -c up -l parallel -d 'parallel user-space'
+complete -c up -l no-emoji -d 'ASCII glyphs only'
+complete -c up -l profile -d 'load profile' -xa '(__up_profiles)'
+complete -c up -l list-profiles -d 'list profiles'
+complete -c up -l snapshot -d 'pre-update snapshot'
+complete -c up -l refresh-mirrors -d 'refresh mirrors'
+complete -c up -l resume -d 'resume interrupted run'
+complete -c up -l reboot -d 'reboot if required'
+complete -c up -s w -l weather -d 'weather line'
+complete -c up -l changed -d 'show package diff'
+complete -c up -l changelog -d 'show changelog' -x
+complete -c up -l history -d 'run history'
+complete -c up -l report -d 'export markdown' -r
+complete -c up -l errors -d 'show errors'
+complete -c up -l self-update -d 'update eMerger'
+complete -c up -l auto-update -d 'install auto-update'
+complete -c up -l rebuild-cache -d 'clear detection cache'
diff --git a/doc/documentation.pdf b/doc/documentation.pdf
index 3e45824..a28f7d8 100644
Binary files a/doc/documentation.pdf and b/doc/documentation.pdf differ
diff --git a/doc/documentation.tex b/doc/documentation.tex
index e6865c1..f167e93 100644
--- a/doc/documentation.tex
+++ b/doc/documentation.tex
@@ -1,117 +1,1902 @@
-\documentclass{article}
-% some package imported, idk if I'll use all of it, otherwiser I will remove them
-\usepackage{amsmath} % import of math elements
-\usepackage{mathtools} %import of other math elements
-\usepackage{tikz}
-\usetikzlibrary{shapes,positioning,calc}
+\documentclass[11pt,a4paper]{article}
%-------------------------------------------------------
-% Document information
+% Encoding and fonts
%-------------------------------------------------------
+\usepackage[T1]{fontenc}
+\usepackage[utf8]{inputenc}
+\usepackage{lmodern}
+\renewcommand{\familydefault}{\sfdefault} % Latin Modern Sans Serif as default
+\usepackage{microtype}
+\usepackage{textcomp}
-\author{
- Roberto Antoniello \ \ \ \ \ \&
- \and
- Edoardo Ferrari} %author name
-
-\title{eMerger Documentation} %Title
+%-------------------------------------------------------
+% Layout
+%-------------------------------------------------------
+\usepackage[a4paper,margin=2.4cm]{geometry}
+\usepackage{parskip}
+\usepackage{enumitem}
+\setlist{itemsep=2pt,topsep=3pt}
+\usepackage{titlesec}
+\titleformat{\section}{\Large\bfseries\sffamily}{\thesection}{0.8em}{}
+\titleformat{\subsection}{\large\bfseries\sffamily}{\thesubsection}{0.7em}{}
+\titleformat{\subsubsection}{\normalsize\bfseries\sffamily}{\thesubsubsection}{0.6em}{}
+
+%-------------------------------------------------------
+% Graphics
+%-------------------------------------------------------
+\usepackage{graphicx}
+
+%-------------------------------------------------------
+% Tables, colors, code
+%-------------------------------------------------------
+\usepackage{array}
+\usepackage{booktabs}
+\usepackage{longtable}
+\usepackage{xcolor}
+\usepackage{tcolorbox}
+\tcbuselibrary{skins,breakable}
+\definecolor{codebg}{HTML}{F4F4F4}
+\definecolor{codefr}{HTML}{DADADA}
+\definecolor{codekw}{HTML}{1F6FEB}
+\definecolor{codest}{HTML}{22863A}
+\definecolor{codecm}{HTML}{6A737D}
+\definecolor{linkcol}{HTML}{0B5FFF}
+\definecolor{tipbg}{HTML}{EAF4FF}
+\definecolor{tipfr}{HTML}{0B5FFF}
+\definecolor{warnbg}{HTML}{FFF4E5}
+\definecolor{warnfr}{HTML}{B07000}
+\definecolor{notebg}{HTML}{F0FFF0}
+\definecolor{notefr}{HTML}{228B22}
+
+\newtcolorbox{tip}{colback=tipbg,colframe=tipfr,boxrule=0.6pt,
+ arc=2pt,left=6pt,right=6pt,top=4pt,bottom=4pt,breakable}
+\newtcolorbox{warn}{colback=warnbg,colframe=warnfr,boxrule=0.6pt,
+ arc=2pt,left=6pt,right=6pt,top=4pt,bottom=4pt,breakable}
+\newtcolorbox{note}{colback=notebg,colframe=notefr,boxrule=0.6pt,
+ arc=2pt,left=6pt,right=6pt,top=4pt,bottom=4pt,breakable}
+
+\usepackage{listings}
+\lstset{
+ basicstyle=\ttfamily\small,
+ backgroundcolor=\color{codebg},
+ frame=single,
+ rulecolor=\color{codefr},
+ framesep=4pt,
+ xleftmargin=6pt,
+ xrightmargin=6pt,
+ breaklines=true,
+ showstringspaces=false,
+ keywordstyle=\color{codekw}\bfseries,
+ stringstyle=\color{codest},
+ commentstyle=\color{codecm}\itshape,
+ columns=fullflexible,
+ keepspaces=true
+}
+\lstdefinelanguage{bashplus}{
+ morekeywords={up,sudo,git,bash,alias,source,apt,dnf,pacman,zypper,brew,flatpak,snap,systemctl,cron,export,case,esac,if,then,fi,elif,for,do,done,while,in,function,return,echo,read,printf,curl,rsync},
+ morecomment=[l]{\#},
+ morestring=[b]",
+ morestring=[b]'
+}
+\lstdefinelanguage{psplus}{
+ morekeywords={function,param,if,else,elseif,switch,return,foreach,for,while,break,continue,Get-ScheduledTask,Register-ScheduledTask,Install-Module,Set-Clipboard,Write-Host,Set-ExecutionPolicy,Join-Path},
+ morecomment=[l]{\#},
+ morestring=[b]",
+ morestring=[b]'
+}
+\lstdefinelanguage{jsonplus}{
+ morestring=[b]",
+ morecomment=[l]{//}
+}
+
+%-------------------------------------------------------
+% Links
+%-------------------------------------------------------
+\usepackage[colorlinks=true,linkcolor=linkcol,urlcolor=linkcol,citecolor=linkcol]{hyperref}
+
+%-------------------------------------------------------
+% Metadata
+%-------------------------------------------------------
+\title{\textbf{eMerger}\\[4pt]\large One command to update your whole system}
+\author{Roberto Antoniello \quad\textbf{\&}\quad Edoardo Ferrari}
+\date{\today}
\begin{document}
-\maketitle % show the title and author and date
+\maketitle
+
+\begin{center}
+\includegraphics[width=0.55\linewidth]{../src/logo/big_name.png}
+\end{center}
+
+\begin{abstract}
+\noindent
+\textbf{eMerger} is a cross-platform command line tool that refreshes,
+upgrades and cleans a computer with a single command: \texttt{up}. It runs
+on \textbf{Linux}, \textbf{macOS} and \textbf{Windows}, auto-detects every
+package manager present on the host and invokes them in the right order,
+returning one unified summary at the end. No daemon. No YAML. No embedded
+DSL. Just a small, auditable script that drives the tools you already have.
+
+This document is written to be readable by anyone: newcomers will find an
+explanation of what a package manager is, what \texttt{up} actually does on
+your machine and how to try it safely; experienced users will find a
+complete reference for every flag, profile, hook, plugin and integration
+point, plus a cookbook of real-world recipes.
+\end{abstract}
+
+\tableofcontents
+\vspace{1em}
+\hrule
+\vspace{1em}
+
+%-------------------------------------------------------
+\section{How to read this document}
+%-------------------------------------------------------
+The document is organised in layers:
+
+\begin{itemize}
+ \item \textbf{Getting started} (sections \ref{sec:why}\,--\,\ref{sec:firstrun}):
+ what eMerger does, why you might want it, and a guided first run
+ that takes less than five minutes.
+ \item \textbf{User manual} (sections \ref{sec:flow}\,--\,\ref{sec:features}):
+ the execution flow, every command line flag with an example, and a
+ feature-by-feature walkthrough.
+ \item \textbf{Configuration} (sections \ref{sec:config}\,--\,\ref{sec:plugins}):
+ config file, profiles, hooks and user plugins, all with
+ copy-pasteable snippets.
+ \item \textbf{Integration} (sections \ref{sec:integration}\,--\,\ref{sec:auto}):
+ JSON, Prometheus, exit codes, CI, cron and systemd timers.
+ \item \textbf{Cookbook} (section \ref{sec:cookbook}): ready-to-use
+ recipes for laptops, servers, developer workstations, presentation
+ machines, CI runners and metered connections.
+ \item \textbf{Reference} (sections \ref{sec:files}\,--\,\ref{sec:glossary}):
+ file locations, supported managers, exit codes, troubleshooting,
+ FAQ and a glossary of terms.
+ \item \textbf{Development} (section \ref{sec:dev}): repo layout, module
+ responsibilities, running tests, adding a package manager.
+\end{itemize}
+
+Throughout the document you will see three kinds of call-outs:
+
+\begin{tip}
+\textbf{Tip} - a useful shortcut or a better way of doing something.
+\end{tip}
+\begin{note}
+\textbf{Note} - background information or a clarification that is worth
+remembering but not strictly required to move on.
+\end{note}
+\begin{warn}
+\textbf{Warning} - something that can surprise you or cost you data if
+ignored. Read these carefully.
+\end{warn}
+
+\clearpage
+
%-------------------------------------------------------
-%Introduction
+\section{Why eMerger?}\label{sec:why}
%-------------------------------------------------------
-\begin{center}In this file we will put a complete documentation of eMerger. So you can read simply how it really works without waste too much time reading only the code.
+\subsection{The problem}
+A modern desktop or laptop runs software that comes from many different
+sources at once:
+
+\begin{itemize}
+ \item the operating system distributor (\texttt{apt}, \texttt{dnf},
+ \texttt{pacman}, \texttt{zypper}, \texttt{softwareupdate},
+ \texttt{winget}\,\dots);
+ \item a cross-distro app store (\texttt{flatpak}, \texttt{snap},
+ \texttt{brew --cask}, \texttt{choco}, \texttt{scoop});
+ \item a user-level store (\texttt{brew}, \texttt{mas});
+ \item language-specific ecosystems (\texttt{npm}, \texttt{pip},
+ \texttt{cargo}, \texttt{gem}, \texttt{pnpm}\,\dots);
+ \item vendor firmware (\texttt{fwupdmgr} on Linux, \texttt{softwareupdate}
+ on macOS, Dell/Lenovo utilities on Windows).
+\end{itemize}
+
+Each uses a slightly different command, has its own cache, its own notion
+of ``security update'' and its own definition of ``clean''. Keeping all of
+them up to date by hand is either tedious or error-prone, and writing a
+personal wrapper that does the right thing on three operating systems is
+a weekend project most people never finish.
+
+\subsection{The solution}
+eMerger is that wrapper, generalised. Typing \texttt{up} on any supported
+platform will:
+
+\begin{enumerate}
+ \item detect every package manager installed on the host;
+ \item ask for \texttt{sudo} (or trigger a UAC elevation on Windows) only
+ if one of the detected managers actually needs it;
+ \item take an optional filesystem snapshot or a package list snapshot
+ so you can audit the run afterwards;
+ \item run refresh / upgrade / clean, in the right order, serially or in
+ parallel where safe;
+ \item clean user caches and the trash, optionally;
+ \item print a boxed summary with per-manager result, disk freed, run
+ duration and a reboot advisory;
+ \item export the same summary as JSON, Markdown or Prometheus textfile
+ if you ask.
+\end{enumerate}
+
+It does this with no daemon, no YAML, no Python runtime and no Ruby
+runtime: just a Bash script on Unix and a PowerShell script on Windows.
+
+\subsection{Design principles}
+\begin{itemize}
+ \item \textbf{One command.} The user types \texttt{up}; everything else
+ is automatic detection, ordering and reporting.
+ \item \textbf{No runtime bloat.} Configuration is a plain shell or
+ PowerShell file that is sourced at startup.
+ \item \textbf{Use what is there.} eMerger never installs a package
+ manager on your behalf; it detects the ones already installed and
+ delegates work to them.
+ \item \textbf{Safe by default.} Dry-run, retries, filesystem snapshots,
+ resume after interruption and a pre-flight health check are all
+ first-class options.
+ \item \textbf{Cross-platform parity.} The core flow (detect, upgrade,
+ clean, summarise) is identical on Linux, macOS and Windows.
+ Platform-specific features are gated and labelled.
+\end{itemize}
+
+\subsection{What eMerger is \emph{not}}
+\begin{itemize}
+ \item It is \textbf{not} a package manager itself. It does not download
+ or install software directly: it asks the native tools to do it.
+ \item It is \textbf{not} a configuration management system. There is no
+ desired-state manifest, no idempotent resource graph. If you want
+ that, use Ansible, Salt or NixOS.
+ \item It is \textbf{not} a scheduler. It can install a weekly systemd
+ timer, cron job or Windows scheduled task, but it does not run as
+ a service.
+\end{itemize}
+
+The project name is a wink at Gentoo's \texttt{emerge}: the tool started
+under the name \textit{Updater} and was later renamed to \textit{eMerger}
+to reflect its goal of merging the quirks of every supported package
+manager behind a uniform interface.
+
+%-------------------------------------------------------
+\section{Platforms at a glance}\label{sec:platforms}
+%-------------------------------------------------------
+
+\begin{center}
+\begin{tabular}{@{}llll@{}}
+\toprule
+ & \textbf{Linux} & \textbf{macOS} & \textbf{Windows} \\
+\midrule
+Entry point & \texttt{src/emerger.sh} & \texttt{src/emerger.sh} & \texttt{src/emerger.ps1}\\
+Shell & bash 3.2+ & bash 3.2+ (system) & PowerShell 5.1+ \\
+Setup & \texttt{./setup.sh} & \texttt{./setup.sh} & \texttt{.\textbackslash setup.ps1} \\
+Uninstall & \texttt{./uninstall.sh} & \texttt{./uninstall.sh} & \texttt{.\textbackslash uninstall.ps1}\\
+Auto-update & systemd timer / cron & cron & Task Scheduler \\
+Elevation & \texttt{sudo} & \texttt{sudo} & UAC relaunch \\
+TUI menu & yes (\texttt{-i}) & yes (\texttt{-i}) & no \\
+Parallel mode & yes & yes & no (always serial) \\
+Snapshots & snapper/timeshift/btrfs & no & no (System Restore manual)\\
+Config dir & \verb|~/.config/emerger/| & \verb|~/.config/emerger/| & \verb|%APPDATA%\emerger\|\\
+State dir & \verb|~/.local/state/emerger/| & \verb|~/.local/state/emerger/| & \verb|%LOCALAPPDATA%\emerger\state\|\\
+\bottomrule
+\end{tabular}
\end{center}
-\section{Introduction}
-We started this project for fun without knowing a bit of Bash, but then we have continued to release new features and improve the main script, also learning new stuffs during the development.\\
-The project started with the name of \textit{Updater}, then we changed to \textit{eMerger}(*blink Gentoo dudes).\\
-Probably you already know if you're reading this but let's repeat what is eMerger.\\
-eMerger is a script that allows users to do a clean update for Linux based system with a single command.
+The core flow is identical across platforms. Features that depend on
+operating system primitives (firmware updates, filesystem snapshots, TUI)
+are gated to the platforms that support them and labelled as such
+throughout this document.
+
+%-------------------------------------------------------
+\section{Requirements}\label{sec:requirements}
+%-------------------------------------------------------
+
+eMerger never installs dependencies for you. The list below is what
+``works out of the box'' vs. what unlocks optional features.
+
+\subsection{Linux}
+\textbf{Required:} \texttt{bash} 3.2+, \texttt{coreutils}, \texttt{git},
+\texttt{sudo}.
+
+\textbf{Optional:}
+\begin{itemize}
+ \item \texttt{gum} or \texttt{whiptail} for a nicer interactive menu.
+ \item \texttt{notify-send} for desktop notifications.
+ \item \texttt{curl} for the weather widget (\texttt{-w}) and
+ \texttt{--self-update} checks.
+ \item \texttt{flock} for the global lock (pre-installed on every
+ mainstream distribution).
+ \item \texttt{reflector} (Arch) or \texttt{netselect-apt} (Debian /
+ Ubuntu) for \texttt{--refresh-mirrors}.
+ \item \texttt{snapper}, \texttt{timeshift} or \texttt{btrfs-progs} for
+ \texttt{--snapshot} and \texttt{--rollback}.
+ \item \texttt{fwupdmgr} for \texttt{--firmware}.
+\end{itemize}
+
+\subsection{macOS}
+\textbf{Required:} the system \texttt{bash} at \texttt{/bin/bash} (3.2+
+is enough; no Homebrew Bash needed), and Xcode Command Line Tools for
+\texttt{git}.
+
+\textbf{Optional:}
+\begin{itemize}
+ \item Homebrew (\texttt{brew}), which in turn enables \texttt{brew --cask}.
+ \item \texttt{mas} (\texttt{brew install mas}) for Mac App Store
+ upgrades.
+\end{itemize}
+
+\subsection{Windows}
+\textbf{Required:} PowerShell 5.1 (built-in on Windows 10 and later) or 7+,
+Git for Windows (needed by \texttt{up --self-update}).
+
+\textbf{Optional:}
+\begin{itemize}
+ \item \texttt{winget}, \texttt{scoop}, \texttt{choco}: any combination
+ is auto-detected.
+ \item \texttt{PSWindowsUpdate}
+ (\texttt{Install-Module PSWindowsUpdate -Scope CurrentUser}) for
+ Windows Update integration.
+ \item \texttt{BurntToast}
+ (\texttt{Install-Module BurntToast -Scope CurrentUser}) for
+ native toast notifications.
+\end{itemize}
+
+\begin{note}
+eMerger never installs these for you - it only uses what is already
+installed. This is deliberate: \texttt{up} must never put software on
+your machine that you didn't ask for.
+\end{note}
+
+%-------------------------------------------------------
+\section{Installation}\label{sec:install}
+%-------------------------------------------------------
+
+\subsection{Linux and macOS}
+
+\begin{lstlisting}[language=bashplus]
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+./setup.sh
+# open a new shell, or: source ~/.bashrc
+up --help
+\end{lstlisting}
+
+\texttt{setup.sh} performs the following actions, in order:
+
+\begin{enumerate}
+ \item Adds \verb|alias up='bash /path/to/eMerger/src/emerger.sh'| to
+ the appropriate shell rc file (\texttt{.bashrc}, \texttt{.zshrc},
+ \texttt{config.fish}). If you use multiple shells it writes the
+ alias in all of them.
+ \item Marks \texttt{src/emerger.sh} as executable.
+ \item Installs shell completions for \texttt{bash}, \texttt{zsh} and
+ \texttt{fish}:
+ \begin{itemize}
+ \item \verb|~/.local/share/bash-completion/completions/up|
+ \item \verb|~/.zsh/completions/_up|
+ \item \verb|~/.config/fish/completions/up.fish|
+ \end{itemize}
+ \item Scaffolds \verb|~/.config/emerger/| with the default
+ \texttt{config.sh}, \texttt{ignore.list}, \texttt{hooks/} and
+ \texttt{profiles.d/} layout.
+\end{enumerate}
+
+On macOS the alias is written to \texttt{\string~/.zshrc} first, since
+\texttt{zsh} is the default interactive shell from Catalina onwards. When
+Homebrew is present, completions are installed under the Homebrew prefix
+so that they are loaded by the \texttt{brew}-managed shell environment.
+
+\subsection{Windows}
-\section{Installation}
-The only thing you need to do to install eMerger is to launch \textit{setup.sh}.\\
-When you launch it for the first time, it just put an alias on your ~/.bashrc named \textit{up} pointed on the source file \textit{emerger.sh}. \\
-After the alias is added correctly or the alias already exists, it will launch an integrity test by calling the source file \textit{integrity-check.sh}.\\
-During the installation a little cache is also created so eMerger will always remember which system you're using without fetching this data every time.
+\begin{lstlisting}[language=psplus]
+git clone https://github.com/MasterCruelty/eMerger
+cd eMerger
+.\setup.ps1
+# open a new PowerShell window, or: . $PROFILE
+up --help
+\end{lstlisting}
-\subsection{Integrity test}
-In this script there's a check of the existence and stability of the content of source files needed for eMerger correct execution. If it's all ok it continues, otherwise the current operation is aborted.
+\texttt{setup.ps1} does \emph{not} require administrator privileges:
+
+\begin{enumerate}
+ \item Lifts \texttt{ExecutionPolicy} for \texttt{CurrentUser} to
+ \texttt{RemoteSigned} when it was \texttt{Restricted} or
+ \texttt{Undefined}.
+ \item Defines \verb|function up { & "...\emerger.ps1" @args }| inside
+ \verb|$PROFILE.CurrentUserAllHosts|, so \texttt{up} works from
+ every PowerShell host (cmd-hosted PS, ISE, Windows Terminal,
+ VS Code integrated terminal).
+ \item Scaffolds \verb|%APPDATA%\emerger\| with
+ \texttt{config.ps1}, \texttt{hooks\textbackslash pre.d\textbackslash},
+ \texttt{hooks\textbackslash post.d\textbackslash},
+ \texttt{profiles.d\textbackslash}.
+\end{enumerate}
+
+When a package manager run later needs elevation, eMerger relaunches
+itself under UAC. The relaunch is logged, so you always know when it
+happened.
+
+\subsection{Manual install}
+
+When a packaged setup is undesirable (read-only home, corporate lockdown,
+portable installation), pointing an alias at the entry point is enough:
+
+\begin{lstlisting}[language=bashplus]
+# bash, zsh, fish - Linux or macOS
+alias up='bash /absolute/path/to/eMerger/src/emerger.sh'
+\end{lstlisting}
+
+\begin{lstlisting}[language=psplus]
+# PowerShell - Windows
+function up { & "C:\path\to\eMerger\src\emerger.ps1" @args }
+\end{lstlisting}
\subsection{Uninstall}
-The script \textit{uninstall.sh} just removes the alias created during the installation and delete every file related to eMerger.
-
-\section{Functioning}
-At this moment, the main source file is \textit{emerger.sh}. \\
-First of all, eMerger starts checking for subcommand(you can view the list by typing \textit{up -help}). if it's a subcommand that offer an extra feature, eMerger executes it and return to shell.
-Otherwise it starts with the real execution by do these steps:\\
-\begin{itemize}
-\item It generates the cache of system information or it fetch the exising one. \\ \\
-\textit{Essentially if it's the first time, it creates a cache to remember the system the next time. Otherwise it fetch the data from the existing cache and it already start with the correct package manager based on the system we are launching eMerger on.}
-\item update packages repository.
-\item upgrade packages.
-\item autoremove not necessary packages.
-\item update/upgrade of external packages manager such as snap and flatpak if they are used by the user on his system.
-\item clean the trashed.
-\end{itemize}
-\subsection{Updating eMerger}
-Obviously there's a way to update the Updater(\textit{yes, we like to joke...)}. Our basic but functional way is to execute a git pull from the main branch of the project, you can do that by typing \textit{up -up}.
-
-\section{OS supported}
-The list of OS supported is always growing to make it to offer the power of eMerger to as many systems as possible and many people as possible. Our goal is to give an alternative to update the system with a single command without remember every time all the commands are needed for a clean update. \\ \\ \\
-\begin{itemize}
-\item Arch Linux
-\item Debian
-\item EndeavourOS
-\item Fedora Project
-\item Kali Linux
-\item Manjaro
-\item Raspbian
-\item Termux
-\item Ubuntu
-\end{itemize}
-\section{Package manager supported}
-And now we list the package managers, not only those which are connected to a os but also those which are dedicated to no-free app external to the main pkg manager of the os.
-\begin{itemize}
-\item rpm
-\item apt-get
-\item apt
-\item pacman
-\item emerge(\textit{I know you already got it about our project name...})
-\item flatpak
-\item nixos
-\item zypper
-\item snap
-\item pkg
-\end{itemize}
-
-\section{Source files organization}
-The organization and how the single source files works is not really clearly. In this chapter we try to describe the most useful one to help understand better the functioning of eMerger.
-\subsection{utils folder}
-Inside the \textbf{utils} folder we can find some source Bash files which helps not to repeat lines of code in the project. The most useful is \textit{global.sh} which saves in variables the ascii code of emojis for every package manager and the definition of \textit{puts} function which is a custom of the printf with different colours based of which type of string we have to show on terminal.\\ \\
-Another source file we can describe a little bit is \textit{cache-gen.sh} which is the one that generates the cache file the first time we try to launch eMerger.
-
-\begin{itemize}
-\item It saves the terminal the user is using.(such as xfce terminal or terminator).
-\item It saves the package manager the system is using.
-\item It saves all of these information inside a file.
-\item In emerger.sh this file will be hashed with md5.
-\end{itemize}
-
-\subsection{package folder}
-Inside this folder there are all the source files for every package manager with their respective commands and flow of actions.
-\section{use cases}
-Here we list the amount of use cases eMerger have in this project.\\
-\begin{itemize}
-\item The user install eMerger by launching \textit{setup.sh}
-\item The user uninstall eMerger by launching \textit{uninstall.sh}
-\item The user launches a normal update by typing \textit{up}
-\item The user launches an update but he would like to knoe the weather by typing \textit{up -w}
-\item The user updates eMerger by typing \textit{up -up}
-\item There's an error, then the user can revel it by typing \textit{up -err}
-\end{itemize}
-There are a few more cases but here we shared the most commons one.\\
-\textbf{If you still have some questions, you can explore our code on the repository or ask directly to us by creating a new issue!}
-\end{document}
\ No newline at end of file
+
+\begin{lstlisting}[language=bashplus]
+./uninstall.sh # Linux / macOS
+.\uninstall.ps1 # Windows
+\end{lstlisting}
+
+The uninstaller removes the shell alias, the cron job or systemd timer,
+and the scheduled task. Configuration and state directories are
+\textbf{kept} so that logs, profiles and hooks survive a reinstall; remove
+them manually to start from a clean slate:
+
+\begin{lstlisting}[language=bashplus]
+rm -rf ~/.config/emerger ~/.local/state/emerger ~/.cache/emerger
+\end{lstlisting}
+
+The repository itself is not removed: \texttt{rm -rf} the directory when
+you are done.
+
+%-------------------------------------------------------
+\section{Self-update}\label{sec:selfupdate}
+%-------------------------------------------------------
+Three equivalent forms are accepted:
+
+\begin{lstlisting}[language=bashplus]
+up -up # short flag
+up --self-update # long flag
+./update.sh # out-of-band script (.\update.ps1 on Windows)
+\end{lstlisting}
+
+Internally a \texttt{git pull --ff-only} is performed inside the clone
+and the new commit range is printed. Non fast-forward updates are refused
+so that local changes never silently disappear. If you want the
+self-update to also run every time you update your system, add it to a
+\texttt{post.d} hook (see section \ref{sec:hooks}).
+
+%-------------------------------------------------------
+\section{Quickstart}\label{sec:quickstart}
+%-------------------------------------------------------
+
+Five minutes. No commitment. Nothing gets upgraded until you say so.
+
+\subsection{Step 1 - dry-run}
+\begin{lstlisting}[language=bashplus]
+up -n
+\end{lstlisting}
+This is the safe preview. It shows which managers were detected and what
+commands \emph{would} be invoked. No \texttt{sudo}, no downloads, no
+changes on disk.
+
+\subsection{Step 2 - real run}
+\begin{lstlisting}[language=bashplus]
+up
+\end{lstlisting}
+Refresh, upgrade, clean, one manager at a time. You will be asked for
+\texttt{sudo} once (Unix) or get a UAC prompt (Windows) only if a detected
+manager needs it.
+
+\subsection{Step 3 - see what changed}
+\begin{lstlisting}[language=bashplus]
+up --changed
+\end{lstlisting}
+Prints the package diff for the last run with \texttt{+} for added,
+\texttt{-} for removed and \texttt{\~} for upgraded.
+
+\subsection{Step 4 - export a report}
+\begin{lstlisting}[language=bashplus]
+up --report last-run.md
+\end{lstlisting}
+Produces a Markdown file with the JSON summary, per-manager results,
+reboot advisory and the full package diff as a table.
+
+\subsection{Step 5 (optional) - weekly unattended update}
+\begin{lstlisting}[language=bashplus]
+up -au
+\end{lstlisting}
+Installs a systemd user timer on Linux, a cron job on macOS or a
+scheduled task on Windows. The trigger fires every Sunday at 10:00
+(local time) with $\pm$1h randomised delay, and always uses
+\texttt{-y -q -nl -ni}.
+
+%-------------------------------------------------------
+\section{Your first real run}\label{sec:firstrun}
+%-------------------------------------------------------
+
+The very first time you run \texttt{up}, you should expect something like
+this on a Debian/Ubuntu desktop with Flatpak and a couple of dev
+toolchains:
+
+\begin{lstlisting}[basicstyle=\ttfamily\footnotesize]
+$ up
+ _____ __ __
+ | ___| | \/ | eMerger v2.0.0
+ | |__ | |\/| | one command for the whole system
+ |____| |_| |_|
+ Ubuntu 24.04 o x86_64 o 2026-04-16 10:12
+ eMerger v2.0.0 o github.com/MasterCruelty/eMerger
+ [1/3] apt OK (38 upgraded, 0 removed, 0 new)
+ [2/3] flatpak OK (5 refreshed)
+ [3/3] fwupd SKIP (--firmware not set)
+ +---------------------------------------------+
+ | eMerger - summary |
+ | duration 42s |
+ | freed 15 MiB |
+ | reboot not required |
+ | errors 0 |
+ +---------------------------------------------+
+\end{lstlisting}
+
+\begin{tip}
+If the colours or the box look wrong, your terminal may not support
+Unicode glyphs. Run \texttt{up --no-emoji} once, or add
+\verb|ARG_NO_EMOJI=1| to your \texttt{config.sh}.
+\end{tip}
+
+\begin{warn}
+\textbf{Do not} run \texttt{up} with \texttt{sudo} at the top level. eMerger
+will ask for \texttt{sudo} itself, only for the managers that need it, and
+will run user-level managers (\texttt{brew}, \texttt{flatpak} user
+remotes, \texttt{npm} user globals, \texttt{pip --user}\,\dots) \emph{as
+you}, not as root. Calling \verb|sudo up| will install user-level packages
+into root's home.
+\end{warn}
+
+%-------------------------------------------------------
+\section{Execution flow}\label{sec:flow}
+%-------------------------------------------------------
+A full run of \texttt{up} performs, in order:
+
+\begin{enumerate}
+ \item Load \texttt{config.sh} (or \texttt{config.ps1}) if present.
+ \item If \texttt{-{}-profile NAME} is on the command line, pre-load
+ \verb|share/profiles/NAME.sh| (or the user override from
+ \verb|~/.config/emerger/profiles.d/|).
+ \item Parse CLI flags. CLI always wins over config and profile defaults.
+ \item If \texttt{QUIET\_HOURS} is set and we are inside the window
+ \emph{and} \texttt{-y} is set, exit immediately (this shields
+ scheduled runs from night-time activity without blocking
+ interactive ones).
+ \item Acquire the global exclusive lock (\verb|/tmp/emerger.lock| via
+ \texttt{flock} on Unix). If another \texttt{up} is already
+ running, abort with exit code 1.
+ \item Print the logo, OS info line and the current timestamp.
+ \item Warn on low battery (<20\% on a laptop) and on low free disk
+ space (default: less than 1024\,MiB on \texttt{/} or \texttt{C:}).
+ \item Cache \texttt{sudo} credentials on Unix - or relaunch elevated on
+ Windows - only when a detected manager actually needs it.
+ \item Snapshot the installed-packages list into \texttt{pkgs.before} for
+ the post-run diff.
+ \item Execute \texttt{pre.d} hooks in alphabetical order.
+ \item For each detected manager, run \textit{refresh}, \textit{upgrade},
+ \textit{clean}; serial for system managers, concurrent for safe
+ user-space ones when \texttt{--parallel} is active.
+ \item Optionally clear user cache and trash (skippable with
+ \texttt{-nc} and \texttt{-nt}).
+ \item Execute \texttt{post.d} hooks.
+ \item Snapshot the installed-packages list into \texttt{pkgs.after} and
+ compute \texttt{pkgs.diff}.
+ \item Render the boxed summary, the reboot advisory and an optional
+ desktop notification.
+ \item Exit 0 on success, 3 if any manager failed, 4 if a reboot is
+ required and \texttt{--reboot-exit} was passed.
+\end{enumerate}
+
+%-------------------------------------------------------
+\section{Command line reference}\label{sec:cli}
+%-------------------------------------------------------
+
+The authoritative reference is \texttt{up -\/-help}; the table below
+summarises every flag and is annotated with the most common uses.
+
+\subsection{Full flag catalogue}
+
+\begin{center}
+\begin{longtable}{@{}p{0.26\linewidth} p{0.50\linewidth} p{0.18\linewidth}@{}}
+\toprule
+\textbf{Flag} & \textbf{Meaning} & \textbf{Platforms} \\
+\midrule \endhead
+\texttt{-h}, \texttt{--help} & Print help text and exit & all \\
+\texttt{-V}, \texttt{--version} & Print \texttt{eMerger X.Y.Z} & all \\
+\texttt{-n}, \texttt{--dry-run} & Preview without running & all \\
+\texttt{-v}, \texttt{--verbose} & Stream manager output live & all \\
+\texttt{-q} / \texttt{-qq} / \texttt{-qqq} & Progressive quiet levels & all \\
+\texttt{-y}, \texttt{--yes} & Assume yes on prompts & all \\
+\texttt{-i}, \texttt{--interactive} & TUI menu (gum / whiptail) & Unix \\
+\texttt{-w}, \texttt{--weather} & Show weather line via wttr.in & all \\
+\texttt{--no-emoji} & Force ASCII glyphs only & all \\
+\texttt{-nl}, \texttt{--no-logo} & Hide logo & all \\
+\texttt{-ni}, \texttt{--no-info} & Hide system info line & all \\
+\texttt{-nc}, \texttt{--no-cache} & Skip user cache cleaning & all \\
+\texttt{-nt}, \texttt{--no-trash} & Skip trash cleaning & all \\
+\texttt{--security} & Security-only updates & supported managers \\
+\texttt{--firmware} & Include \texttt{fwupdmgr} & Linux \\
+\texttt{--no-firmware} & Force-skip firmware & Linux \\
+\texttt{--dev} & Include dev toolchains & all \\
+\texttt{--parallel} & Run user-space in parallel & Unix \\
+\texttt{--profile NAME} & Load a profile & all \\
+\texttt{--list-profiles} & List available profiles & all \\
+\texttt{--only LIST} & Only these managers & all \\
+\texttt{--except LIST} & Skip these managers & all \\
+\texttt{--snapshot} & snapper / timeshift / btrfs & Linux \\
+\texttt{--rollback} & Revert last eMerger snapshot & Linux \\
+\texttt{--refresh-mirrors} & Re-rank distro mirrors & Linux \\
+\texttt{--resume} & Skip completed managers & Unix \\
+\texttt{--reboot} & Reboot if required & all \\
+\texttt{--reboot-exit} & Exit 4 if reboot required & all \\
+\texttt{--download-only}, \texttt{--offline} & Prefetch, don't install & Unix \\
+\texttt{--changed} & Show package diff & Unix \\
+\texttt{--changelog PKG} & Upstream changelog for PKG & Unix \\
+\texttt{--report [FILE]} & Export Markdown report & Unix \\
+\texttt{--history} & Recent runs & all \\
+\texttt{-err}, \texttt{--errors} & Tail of logged errors & all \\
+\texttt{--doctor} & Health check & all \\
+\texttt{--json} & Machine-readable summary & all \\
+\texttt{--metrics FILE} & Prometheus textfile export & all \\
+\texttt{-up}, \texttt{--self-update} & Self-update eMerger & all \\
+\texttt{-au}, \texttt{--auto-update} & Install weekly auto-update & all \\
+\texttt{-rc}, \texttt{--rebuild-cache} & Clear detection cache & all \\
+\bottomrule
+\end{longtable}
+\end{center}
+
+\subsection{Combining flags}
+Flags are independent tokens and can be combined freely, in any order.
+Single-letter short flags in the set \texttt{\{h V n v q y i w\}} bundle
+(\texttt{up -nv} equals \texttt{up -n -v}; \texttt{up -ynv} equals
+\texttt{up -y -n -v}). Compound short flags (\texttt{-nl}, \texttt{-ni},
+\texttt{-qq}, \texttt{-up}, \texttt{-err}\,\dots) and long flags are never
+bundled.
+
+\begin{lstlisting}[language=bashplus]
+up -n -v # dry-run with live stream
+up -y -q --security # unattended security-only, minimal output
+up --dev --parallel -v # dev toolchains + user-space concurrency
+up --snapshot --reboot -y # snapshot first, reboot at the end if needed
+up --profile server --resume # resume an interrupted headless run
+up -n --dev --firmware # preview a full run, no side effects
+up -qq -y -nl -ni --security # exactly what the scheduled timer runs
+up --refresh-mirrors -y -v # re-rank mirrors then upgrade, live
+up --changed --report out.md # show diff and export in one shot
+\end{lstlisting}
+
+Flags that take a value (\texttt{--profile NAME}, \texttt{--changelog PKG},
+\texttt{--report FILE}, \texttt{--metrics FILE}, \texttt{--only LIST},
+\texttt{--except LIST}) must keep their argument adjacent; every other
+flag is position-free. CLI flags always win over config file and profile
+defaults, so you can override a profile on the fly:
+
+\begin{lstlisting}[language=bashplus]
+up --profile work --dev # work profile, but force --dev this time
+\end{lstlisting}
+
+%-------------------------------------------------------
+\section{Features in depth}\label{sec:features}
+%-------------------------------------------------------
+
+\subsection{Dry-run and verbose}
+\texttt{-n} prints every command that \emph{would} be executed without
+actually running it or asking for elevation, which makes it safe to run
+in a terminal that does not own \texttt{sudo} credentials. Example
+output:
+
+\begin{lstlisting}[basicstyle=\ttfamily\footnotesize]
+$ up -n
+ [dry] sudo apt update
+ [dry] sudo apt upgrade -y
+ [dry] sudo apt autoremove -y
+ [dry] sudo apt clean
+ [dry] flatpak update -y
+ [dry] flatpak uninstall --unused -y
+\end{lstlisting}
+
+\texttt{-v} streams the output of each package manager live instead of
+buffering it into the summary. The two combine naturally:
+\texttt{up -n -v}.
+
+\subsection{Quiet levels}
+\begin{itemize}
+ \item \textbf{default} - full UI with logo, info line, step headers,
+ spinner, summary box.
+ \item \texttt{-q} - hides muted and informational lines but keeps the
+ per-manager result and the summary.
+ \item \texttt{-qq} - only step titles and the one-line summary; suitable
+ for cron/systemd logs.
+ \item \texttt{-qqq} - no output at all; the exit code is the only
+ signal. Suitable for embedding in shell pipelines.
+\end{itemize}
+
+\subsection{Security-only updates}
+When \texttt{--security} is passed, each supporting manager is asked to
+limit the upgrade set to security advisories:
+
+\begin{itemize}
+ \item \textbf{apt} via \texttt{unattended-upgrade --security} (falls
+ back to \texttt{apt upgrade} for packages that are not in the
+ unattended security tree).
+ \item \textbf{dnf} with \texttt{upgrade --security}.
+ \item \textbf{zypper} with \texttt{patch --category security}.
+ \item \textbf{softwareupdate} (macOS) with \texttt{--recommended}.
+ \item \textbf{PSWindowsUpdate} (Windows) with severity filtering.
+\end{itemize}
+
+Managers that do not distinguish between security and non-security
+updates simply ignore the flag and skip the run.
+
+\subsection{Dev toolchains}
+\texttt{--dev} enables a separate track that runs:
+
+\begin{center}
+\begin{tabular}{@{}ll@{}}
+\toprule
+\textbf{Tool} & \textbf{Command} \\
+\midrule
+\texttt{rustup} & \texttt{rustup self update \&\& rustup update stable} \\
+\texttt{cargo} & \texttt{cargo install-update -a} \\
+\texttt{npm} & \texttt{npm update -g} \\
+\texttt{pnpm} & \texttt{pnpm -g update} \\
+\texttt{pip} & \texttt{pip list --outdated}, then user-site upgrade \\
+\texttt{gem} & \texttt{gem update} \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+These run under the invoking user, never under \texttt{sudo}. If a
+toolchain isn't installed it is silently skipped.
+
+\subsection{Firmware (Linux)}
+\texttt{--firmware} runs
+\texttt{fwupdmgr refresh \&\& fwupdmgr update -y --no-reboot-check}.
+If the LVFS (Linux Vendor Firmware Service) is not reachable the step is
+skipped with a warning. On macOS firmware is handled by
+\texttt{softwareupdate}; on Windows it is delegated to vendor tooling
+(Dell Command Update, Lenovo Vantage\,\dots) and is out of scope.
+
+\subsection{Parallel mode}
+\texttt{--parallel} runs user-space managers that do not touch the root
+filesystem concurrently: \texttt{flatpak}, \texttt{snap}, \texttt{brew},
+\texttt{mas}, and the dev toolchains. System managers (\texttt{apt},
+\texttt{dnf}, \texttt{pacman}, \texttt{zypper}\,\dots) stay serial to
+avoid lock contention on \texttt{/var/lib/dpkg}, \texttt{/var/cache/pacman}
+and similar. The Windows side is currently always serial.
+
+\subsection{Snapshots and rollback (Linux)}
+\texttt{--snapshot} tries, in order:
+
+\begin{enumerate}
+ \item \texttt{snapper} - creates a pre-update snapshot tagged
+ \texttt{eMerger pre-update} on the root subvolume.
+ \item \texttt{timeshift} - runs \texttt{timeshift --create --tags D}.
+ \item raw \texttt{btrfs subvolume snapshot} - only if neither of the
+ above is installed; the snapshot is placed under
+ \verb|/.snapshots/emerger/|.
+\end{enumerate}
+
+\texttt{--rollback} reverts to the most recent eMerger-created snapshot:
+
+\begin{itemize}
+ \item \textbf{snapper}: native
+ \texttt{snapper -c root rollback }. A reboot is required
+ afterwards (snapper semantics, not ours).
+ \item \textbf{timeshift}: hands off to \texttt{timeshift --restore}
+ \emph{interactively} - silent rollback is too destructive to
+ automate.
+ \item \textbf{raw btrfs}: refuses to swap subvolumes automatically,
+ prints the path of the latest snapshot so you can do it
+ manually.
+\end{itemize}
+
+\begin{tip}
+Combine with a guard to get a safe update cycle:
+
+\begin{lstlisting}[language=bashplus]
+up --snapshot -y || up --rollback
+\end{lstlisting}
+\end{tip}
+
+\subsection{Mirrors refresh (Linux)}
+\texttt{--refresh-mirrors} re-ranks the repository mirrors before the
+upgrade:
+
+\begin{itemize}
+ \item \textbf{Arch}: \texttt{reflector --latest 20 --sort rate}.
+ \item \textbf{Debian/Ubuntu}: \texttt{netselect-apt} (requires
+ \texttt{netselect-apt} installed).
+ \item \textbf{Fedora}: no-op; the \texttt{fastestmirror} DNF plugin
+ already ranks mirrors at download time.
+\end{itemize}
+
+\subsection{Reboot handling}
+On Unix, eMerger inspects \texttt{/var/run/reboot-required} and
+\texttt{needs-restarting -r} (if available). On Windows it reads the
+\texttt{CBS RebootPending},
+\texttt{WindowsUpdate\textbackslash RebootRequired} and
+\texttt{PendingFileRenameOperations} registry entries.
+
+\begin{itemize}
+ \item The summary always prints a reboot advisory.
+ \item \texttt{--reboot} actually reboots the machine (requires
+ elevation).
+ \item \texttt{--reboot-exit} exits with code \textbf{4} so that an
+ orchestrator can decide what to do next.
+\end{itemize}
+
+\subsection{Resume}
+When a run is interrupted (Ctrl-C, lost network, disk full, laptop
+suspend), a cursor is written to \verb|~/.local/state/emerger/resume|
+after every successful manager. A subsequent \texttt{up --resume} skips
+every manager that completed successfully, so repeated failures do not
+force a full retry. Unix only.
+
+\subsection{Download-only / offline}
+\texttt{--download-only} (alias \texttt{--offline}) refreshes the package
+indexes and \emph{downloads} the pending upgrade set but does not install
+it. Supported natively on \texttt{pacman} (\texttt{-Syuw}), \texttt{apt}
+(\texttt{--download-only}), \texttt{dnf} (\texttt{--downloadonly}) and
+\texttt{zypper} (\texttt{update --download-only}). Typical use cases:
+
+\begin{itemize}
+ \item laptop on a slow or metered connection: prefetch while on good
+ Wi-Fi, install later at the office;
+ \item servers in a maintenance window: prestage the set, flip to
+ install-only when the change ticket opens;
+ \item pre-flight for \texttt{--snapshot}: verify the whole update set
+ is downloaded \emph{before} taking a snapshot.
+\end{itemize}
+
+\subsection{Package diff and changelog}
+Each run records the installed package set before and after. \texttt{up
+--changed} prints the diff with \texttt{+}, \texttt{-} and \texttt{\~}
+markers:
+
+\begin{lstlisting}[basicstyle=\ttfamily\footnotesize]
+$ up --changed
+ ~ firefox 123.0.1 -> 124.0
+ ~ libc6 2.39-0 -> 2.39-1
+ + flatpak-xdg-utils 1.0.6
+ - old-package 2.1
+\end{lstlisting}
+
+\texttt{up --changelog PKG} dispatches to \texttt{apt changelog},
+\texttt{dnf changelog} (or \texttt{dnf updateinfo} if available),
+\texttt{pacman -Qi} or \texttt{brew log} depending on the host.
+
+\subsection{Reports, history and errors}
+\begin{itemize}
+ \item \texttt{--report FILE} exports a Markdown report of the last
+ run. Without a filename it writes to
+ \verb|$PWD/emerger-report.md|.
+ \item \texttt{--history} prints the last 10 runs as a table.
+ \item \texttt{-err} / \texttt{--errors} tails \texttt{ERROR} lines from
+ \verb|~/.local/state/emerger/emerger.log|.
+\end{itemize}
+
+\subsection{Doctor}
+\texttt{--doctor} audits the environment and prints a traffic-light
+report:
+
+\begin{itemize}
+ \item shell / PowerShell version;
+ \item \texttt{sudo} cache / admin status;
+ \item disk space on the state partition;
+ \item network reachability (IPv4 and IPv6);
+ \item state directory writability;
+ \item per-manager native health: \texttt{dpkg --audit},
+ \texttt{pacman -Dk}, \texttt{brew doctor}, \texttt{pip check};
+ \item pending reboot flag;
+ \item (Windows) the current ExecutionPolicy and elevation status.
+\end{itemize}
+
+A non-zero exit code is returned when any issue is detected. Always run
+\texttt{up --doctor} before opening a bug report.
+
+%-------------------------------------------------------
+\section{Configuration}\label{sec:config}
+%-------------------------------------------------------
+
+\subsection{Config file (Unix)}
+
+\verb|~/.config/emerger/config.sh| is a plain shell file sourced before
+argument parsing. Every flag has a corresponding \texttt{ARG\_*} variable:
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/config.sh
+
+# Defaults
+ARG_DEV=1 # always include dev toolchains
+ARG_WEATHER=1 # show weather widget via wttr.in
+ARG_PARALLEL=1 # run user-space managers in parallel
+ARG_NO_TRASH=1 # never empty the trash automatically
+
+# Thresholds
+DISK_MIN_FREE_MB=2048 # require at least 2 GiB free on /
+BATTERY_MIN_PCT=25 # warn below 25%
+RETRY_MAX=3 # transient-error retry count
+RETRY_DELAY=5 # seconds between retries
+
+# Scheduling
+QUIET_HOURS="23:00-07:00" # scheduled runs exit immediately inside this window
+
+# Caching
+EMERGER_CACHE_TTL=3600 # detection cache: 1h (0 = disabled, default 86400)
+\end{lstlisting}
+
+\subsection{Config file (Windows)}
+
+\verb|%APPDATA%\emerger\config.ps1| is dot-sourced before argument
+parsing:
+
+\begin{lstlisting}[language=psplus]
+# %APPDATA%\emerger\config.ps1
+
+$script:ArgsGlobal.Dev = $true
+$script:ArgsGlobal.Security = $true
+$script:ArgsGlobal.NoTrash = $true
+
+# Thresholds (same semantics as Unix)
+$script:DISK_MIN_FREE_MB = 4096
+$script:RETRY_MAX = 3
+\end{lstlisting}
+
+\subsection{Every supported variable}
+
+\begin{center}
+\begin{longtable}{@{}p{0.34\linewidth} p{0.18\linewidth} p{0.40\linewidth}@{}}
+\toprule
+\textbf{Variable} & \textbf{Default} & \textbf{Meaning} \\
+\midrule \endhead
+\texttt{ARG\_DEV} & 0 & include dev toolchains by default \\
+\texttt{ARG\_FIRMWARE} & 0 & include \texttt{fwupdmgr} by default \\
+\texttt{ARG\_NO\_FIRMWARE} & 0 & force-skip firmware \\
+\texttt{ARG\_SECURITY} & 0 & security-only by default \\
+\texttt{ARG\_YES} & 0 & assume yes by default \\
+\texttt{ARG\_PARALLEL} & 0 & parallel user-space by default \\
+\texttt{ARG\_WEATHER} & 0 & show weather widget \\
+\texttt{ARG\_NO\_EMOJI} & 0 & force ASCII glyphs \\
+\texttt{ARG\_NO\_CACHE} & 0 & skip user cache cleaning \\
+\texttt{ARG\_NO\_TRASH} & 0 & skip trash cleaning \\
+\texttt{ARG\_NO\_LOGO} & 0 & hide logo \\
+\texttt{ARG\_NO\_INFO} & 0 & hide system info line \\
+\texttt{QUIET\_LEVEL} & 0 & 0=full UI, 1=\texttt{-q}, 2=\texttt{-qq}, 3=\texttt{-qqq} \\
+\texttt{DISK\_MIN\_FREE\_MB} & 1024 & abort / warn below this many MiB \\
+\texttt{BATTERY\_MIN\_PCT} & 20 & warn below this battery percent \\
+\texttt{RETRY\_MAX} & 2 & transient-error retries \\
+\texttt{RETRY\_DELAY} & 3 & seconds between retries \\
+\texttt{QUIET\_HOURS} & (unset) & \texttt{"HH:MM-HH:MM"} - skip scheduled runs inside window \\
+\texttt{EMERGER\_CACHE\_TTL} & 86400 & detection-cache TTL in seconds; 0 disables \\
+\bottomrule
+\end{longtable}
+\end{center}
+
+\begin{note}
+The CLI always wins. \verb|ARG_SECURITY=1| in \texttt{config.sh} plus
+\texttt{up --dev} on the CLI will run security updates \emph{and} dev
+toolchains; passing \texttt{--no-firmware} will override
+\verb|ARG_FIRMWARE=1|.
+\end{note}
+
+\subsection{Profiles}\label{sec:profiles}
+Profiles are named configuration snippets loaded through
+\texttt{--profile NAME}. They are ordinary config-file fragments scoped
+to a name.
+
+The shipped set lives under \texttt{share/profiles/} and includes:
+
+\begin{center}
+\begin{tabular}{@{}p{0.16\linewidth} p{0.78\linewidth}@{}}
+\toprule
+\textbf{Profile} & \textbf{Meant for} \\
+\midrule
+\texttt{work} & laptop at the office - security-only, unattended, no
+ cache or trash wiping \\
+\texttt{home} & desktop at home - everything, including dev
+ toolchains, firmware and parallel mode \\
+\texttt{server} & headless - quiet (\texttt{-qq}), security only, no
+ prompts, no UI \\
+\texttt{safe} & before a demo or presentation - security only, no heavy
+ downloads \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+User profiles go in \verb|~/.config/emerger/profiles.d/| (Unix) or
+\verb|%APPDATA%\emerger\profiles.d\| (Windows). They shadow the shipped
+defaults: a user \texttt{work.sh} wins over \texttt{share/profiles/work.sh}.
+
+Full content of the shipped \texttt{work} profile:
+
+\begin{lstlisting}[language=bashplus]
+# description: laptop at work - security only, no firmware, never reboot
+ARG_SECURITY=1
+ARG_YES=1
+ARG_NO_FIRMWARE=1
+ARG_NO_CACHE=1
+ARG_NO_TRASH=1
+\end{lstlisting}
+
+Writing a custom profile is just a shell fragment:
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/profiles.d/train.sh
+# description: on a train, prefetch only, keep the fans quiet
+ARG_DOWNLOAD_ONLY=1
+ARG_YES=1
+ARG_QUIET=1
+QUIET_LEVEL=2
+ARG_NO_TRASH=1
+ARG_NO_CACHE=1
+\end{lstlisting}
+
+Then:
+
+\begin{lstlisting}[language=bashplus]
+up --profile train
+\end{lstlisting}
+
+List what is available:
+
+\begin{lstlisting}[language=bashplus]
+up --list-profiles
+\end{lstlisting}
+
+%-------------------------------------------------------
+\section{Hooks}\label{sec:hooks}
+%-------------------------------------------------------
+
+Executable scripts in \texttt{hooks/pre.d/} and \texttt{hooks/post.d/}
+run before and after the update flow, in alphabetical order. A failing
+hook emits a warning but does not abort the run, so hooks are safe by
+default. Unix hooks are \texttt{*.sh} under bash; Windows hooks are
+\texttt{*.ps1} dot-sourced under PowerShell.
+
+\subsection{Example: backup your dotfiles before every run}
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/hooks/pre.d/10-backup-dotfiles.sh
+#!/usr/bin/env bash
+set -e
+rsync -a --delete ~/.config/ ~/backups/dotfiles/
+\end{lstlisting}
+
+\subsection{Example: Slack notification after every run}
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/hooks/post.d/99-slack-notify.sh
+#!/usr/bin/env bash
+state=~/.local/state/emerger
+tail -1 "$state/history.jsonl" | \
+ curl -sS -X POST -H 'Content-Type: application/json' \
+ --data-binary @- "$SLACK_WEBHOOK_URL" >/dev/null
+\end{lstlisting}
+
+\subsection{Example: copy the last run log to the clipboard (Windows)}
+
+\begin{lstlisting}[language=psplus]
+# %APPDATA%\emerger\hooks\post.d\10-log-to-clipboard.ps1
+$log = Join-Path $env:LOCALAPPDATA 'emerger\state\emerger.log'
+Get-Content $log -Tail 40 | Set-Clipboard
+\end{lstlisting}
+
+\subsection{Example: auto self-update before upgrading}
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/hooks/pre.d/00-self-update.sh
+#!/usr/bin/env bash
+( cd "$(dirname "$(readlink -f "$(command -v up | awk '{print $NF}')")")/.." && git pull --ff-only ) || true
+\end{lstlisting}
+
+\begin{warn}
+Hooks run with the privileges that invoked \texttt{up}. A \texttt{pre.d}
+hook that writes outside of \texttt{\$HOME} may silently fail until
+\texttt{sudo} has been cached later in the run. If you need root-owned
+side effects, write them in \texttt{post.d} and guard with
+\texttt{sudo -n} or a \texttt{sudoers} entry.
+\end{warn}
+
+\subsection{Ignore list (Linux)}
+\verb|~/.config/emerger/ignore.list| accepts one package per line, with
+\texttt{\#} comments. Behaviour per manager:
+
+\begin{center}
+\begin{tabular}{@{}p{0.15\linewidth} p{0.78\linewidth}@{}}
+\toprule
+\textbf{Manager} & \textbf{Behaviour} \\
+\midrule
+\texttt{pacman} & Honoured natively via \texttt{--ignore=} on the
+ command line. \\
+\texttt{apt} & Advisory: you must still \texttt{apt-mark hold PKG}. \\
+\texttt{dnf} & Advisory: you must \texttt{dnf versionlock add PKG}. \\
+\texttt{zypper} & Advisory: you must \texttt{zypper al PKG}. \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+\subsection{Quiet hours}
+\texttt{QUIET\_HOURS="HH:MM-HH:MM"} in \texttt{config.sh} makes scheduled
+runs that start inside the window exit immediately when \texttt{-y} is
+set (i.e. the ones coming from the timer). Interactive invocations are
+never blocked. The window may cross midnight: \texttt{"23:00-07:00"} is
+valid.
+
+%-------------------------------------------------------
+\section{Manager plugins}\label{sec:plugins}
+%-------------------------------------------------------
+
+Dropping a bash script in \verb|~/.config/emerger/managers.d/.sh|
+registers a custom manager without touching the repository.
+
+\subsection{Minimal plugin}
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/managers.d/mytool.sh
+PM_PLUGIN_SLUG=mytool
+
+pm_mytool_detect() { command -v mytool >/dev/null 2>&1; }
+pm_mytool_needs_sudo() { return 1; } # optional, default: no sudo
+pm_mytool_parallel() { return 0; } # optional, default: serial
+pm_mytool_dev() { return 1; } # optional, default: not --dev gated
+pm_mytool_icon() { printf '\xf0\x9f\x94\x8c'; } # optional
+
+pm_mytool_run() {
+ run_cmd "mytool refresh" mytool refresh || return 1
+ run_cmd "mytool upgrade" mytool upgrade -y || return 1
+ return 0
+}
+\end{lstlisting}
+
+\subsection{What you get for free}
+Plugins are registered at the same level as native managers: they honour
+\texttt{--only}, \texttt{--except}, \texttt{--parallel}, \texttt{--dev},
+the detection cache, the hooks and the summary. Because
+\texttt{pm\_\_run} calls \texttt{run\_cmd}, your plugin
+automatically gets:
+
+\begin{itemize}
+ \item \texttt{--dry-run} support;
+ \item retry on transient failures (\texttt{RETRY\_MAX}, \texttt{RETRY\_DELAY});
+ \item logging to \verb|~/.local/state/emerger/emerger.log|;
+ \item live-log streaming under \texttt{-v}.
+\end{itemize}
+
+A copy-pasteable template lives at \texttt{share/plugins/example.sh}.
+
+\subsection{Detection cache}
+The detection cache is keyed by manager slug and lives at
+\verb|~/.cache/emerger/detected|. TTL defaults to 1 day; override via
+\texttt{EMERGER\_CACHE\_TTL=} in \texttt{config.sh} (0 disables
+caching entirely). After installing or removing a package manager run
+\texttt{up -rc} to clear the cache.
+
+\begin{note}
+Plugins are Linux and macOS only. Windows plugins are not yet supported.
+\end{note}
+
+%-------------------------------------------------------
+\section{Integration}\label{sec:integration}
+%-------------------------------------------------------
+
+\subsection{JSON output}
+\texttt{up --json} emits a single-line JSON object on stdout. The logo,
+info line and summary box are all suppressed, so the output is safe to
+pipe into \texttt{jq} or consume from a CI job:
+
+\begin{lstlisting}[language=jsonplus]
+{"ts":"2026-04-14T07:24:31Z","duration":42,"freed_kb":15360,
+ "errors":0,"reboot":0,
+ "managers":[{"name":"apt","result":"ok"},{"name":"flatpak","result":"ok"}]}
+\end{lstlisting}
+
+Every run also appends one such line to
+\verb|~/.local/state/emerger/history.jsonl| (one JSON per line, a.k.a.
+JSONL). Fields:
+
+\begin{center}
+\begin{tabular}{@{}p{0.20\linewidth} p{0.72\linewidth}@{}}
+\toprule
+\textbf{Field} & \textbf{Meaning} \\
+\midrule
+\texttt{ts} & ISO 8601 UTC timestamp of the run start \\
+\texttt{duration} & total wall-clock seconds \\
+\texttt{freed\_kb} & cache and trash bytes freed, in KiB \\
+\texttt{errors} & number of managers that returned non-zero \\
+\texttt{reboot} & 1 if a reboot is required, else 0 \\
+\texttt{managers} & array of \texttt{\{name, result\}} entries \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+\subsection{Prometheus metrics}
+\texttt{up --metrics FILE} reads the most recent entry of
+\texttt{history.jsonl} and writes a Prometheus textfile-collector
+snapshot. Exposed gauges:
+
+\begin{itemize}
+ \item \texttt{emerger\_last\_run\_timestamp\_seconds}
+ \item \texttt{emerger\_last\_run\_duration\_seconds}
+ \item \texttt{emerger\_last\_run\_freed\_bytes}
+ \item \texttt{emerger\_last\_run\_errors}
+ \item \texttt{emerger\_reboot\_required}
+ \item \texttt{emerger\_manager\_ok\{manager="..."\}} (one per manager)
+\end{itemize}
+
+The flag does \emph{not} trigger an update run. Invoke it from a
+\texttt{post.d} hook or from the scheduler after \texttt{up} has
+completed:
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/hooks/post.d/90-prom.sh
+up --metrics /var/lib/node_exporter/textfile_collector/emerger.prom
+\end{lstlisting}
+
+\subsection{Reboot exit code}
+By default \texttt{up} exits 0 on success even when a reboot is pending
+(the summary prints \texttt{REBOOT RECOMMENDED}). Pass
+\texttt{--reboot-exit} to turn that into exit code \textbf{4} instead,
+so an orchestrator can react:
+
+\begin{lstlisting}[language=bashplus]
+up -y --reboot-exit
+case $? in
+ 0) ;; # done, no reboot needed
+ 3) notify-send "eMerger: some managers failed" ;;
+ 4) systemctl reboot ;; # clean, reboot required
+esac
+\end{lstlisting}
+
+\subsection{Download-only and offline}
+\texttt{--download-only} (alias \texttt{--offline}) refreshes the package
+indexes and \emph{downloads} the pending upgrade set but does not install
+it. Supported natively on:
+
+\begin{center}
+\begin{tabular}{@{}ll@{}}
+\toprule
+\textbf{Manager} & \textbf{Command} \\
+\midrule
+\texttt{pacman} & \texttt{pacman -Syuw} \\
+\texttt{apt} & \texttt{apt-get upgrade --download-only} \\
+\texttt{dnf} & \texttt{dnf upgrade --downloadonly} \\
+\texttt{zypper} & \texttt{zypper update --download-only} \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+Other managers ignore the flag.
+
+\subsection{Manager filtering}
+\begin{itemize}
+ \item \texttt{--only apt,flatpak} restricts the run to the listed
+ managers (comma-separated).
+ \item \texttt{--except snap,fwupd} runs everything that would normally
+ run, minus the listed managers.
+\end{itemize}
+
+Filtering is applied after detection and after \texttt{--dev} /
+\texttt{--firmware} gating, so:
+
+\begin{itemize}
+ \item \texttt{--only X} with \texttt{X} not detected is a no-op
+ (nothing runs).
+ \item \texttt{--except} always wins over \texttt{--only} when both
+ mention the same name.
+\end{itemize}
+
+Compose with profiles (\texttt{up --profile work --only apt}) to restrict
+a profile on the fly.
+
+%-------------------------------------------------------
+\section{Auto-update (unattended)}\label{sec:auto}
+%-------------------------------------------------------
+
+\begin{lstlisting}[language=bashplus]
+up -au # Linux / macOS
+\end{lstlisting}
+\begin{lstlisting}[language=psplus]
+up -au # Windows
+\end{lstlisting}
+
+Backends chosen automatically:
+
+\begin{itemize}
+ \item \textbf{Linux}: systemd user timer (preferred) at
+ \verb|~/.config/systemd/user/emerger.{service,timer}|; cron
+ fallback when \texttt{systemctl --user} is not available.
+ \item \textbf{macOS}: cron fallback via \texttt{crontab -l} (you can
+ use \texttt{launchd} manually if you prefer).
+ \item \textbf{Windows}: \texttt{Register-ScheduledTask -TaskName eMerger}.
+\end{itemize}
+
+The trigger fires weekly on Sunday at 10:00 (local time) with $\pm$1h
+randomised delay, and always uses \texttt{-y -q -nl -ni}.
+
+\subsection{Managing the schedule}
+
+\begin{lstlisting}[language=bashplus]
+# Linux
+systemctl --user status emerger.timer
+systemctl --user stop emerger.timer
+systemctl --user disable emerger.timer
+\end{lstlisting}
+
+\begin{lstlisting}[language=psplus]
+# Windows
+Get-ScheduledTask eMerger
+Unregister-ScheduledTask eMerger
+\end{lstlisting}
+
+\begin{tip}
+To avoid night-time runs, pair with \texttt{QUIET\_HOURS} in
+\texttt{config.sh}. Scheduled runs started inside the window exit
+immediately; interactive ones are unaffected.
+\end{tip}
+
+%-------------------------------------------------------
+\section{Cookbook}\label{sec:cookbook}
+%-------------------------------------------------------
+
+Concrete, copy-pasteable recipes for common situations.
+
+\subsection{Daily driver (Linux desktop)}
+\begin{lstlisting}[language=bashplus]
+up -v
+\end{lstlisting}
+Simple. Live output, everything updated, cache and trash wiped at the
+end. Add \texttt{--firmware} occasionally.
+
+\subsection{Unattended server}
+Install the schedule once:
+
+\begin{lstlisting}[language=bashplus]
+cat > ~/.config/emerger/config.sh <<'EOF'
+ARG_SECURITY=1
+ARG_YES=1
+ARG_NO_CACHE=1
+ARG_NO_TRASH=1
+QUIET_HOURS="08:00-18:00"
+EOF
+up -au
+\end{lstlisting}
+
+You now get weekly security-only updates outside working hours. Pair
+with Prometheus:
+
+\begin{lstlisting}[language=bashplus]
+# post.d hook
+up --metrics /var/lib/node_exporter/textfile_collector/emerger.prom
+\end{lstlisting}
+
+\subsection{Developer workstation}
+Everything, every time - including dev toolchains and firmware:
+
+\begin{lstlisting}[language=bashplus]
+up --dev --firmware --parallel -v
+\end{lstlisting}
+
+Or as a profile:
+
+\begin{lstlisting}[language=bashplus]
+up --profile home
+\end{lstlisting}
+
+\subsection{Pre-demo machine}
+The day before a presentation you want security patches but no big user
+downloads and definitely no reboots:
+
+\begin{lstlisting}[language=bashplus]
+up --profile safe
+\end{lstlisting}
+
+\subsection{Metered / mobile connection}
+Prefetch when you're on Wi-Fi:
+
+\begin{lstlisting}[language=bashplus]
+up --download-only -y
+\end{lstlisting}
+
+Then, later, without network:
+
+\begin{lstlisting}[language=bashplus]
+up -y
+\end{lstlisting}
+
+Native package managers will use the local cache populated by the
+earlier run.
+
+\subsection{CI runner / container image builder}
+\begin{lstlisting}[language=bashplus]
+up --only apt -y -qq --reboot-exit
+rc=$?
+if (( rc == 4 )); then echo "reboot required"; exit 0; fi
+exit $rc
+\end{lstlisting}
+
+\subsection{Gated maintenance window}
+Snapshot, update, rollback on failure, reboot on success:
+
+\begin{lstlisting}[language=bashplus]
+set -e
+up --snapshot -y || { up --rollback; exit 1; }
+up -y --reboot-exit
+if [[ $? -eq 4 ]]; then systemctl reboot; fi
+\end{lstlisting}
+
+\subsection{Slack-notified runs}
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/hooks/post.d/99-slack.sh
+#!/usr/bin/env bash
+state=~/.local/state/emerger
+payload=$(tail -1 "$state/history.jsonl")
+curl -sS -X POST -H 'Content-Type: application/json' \
+ -d "{\"text\":\"eMerger: $payload\"}" "$SLACK_WEBHOOK_URL"
+\end{lstlisting}
+
+\subsection{macOS with Homebrew only}
+\begin{lstlisting}[language=bashplus]
+up --only brew,mas -v
+\end{lstlisting}
+
+\subsection{Windows without Chocolatey}
+\begin{lstlisting}[language=psplus]
+up --except choco -v
+\end{lstlisting}
+
+\subsection{Audit a single package}
+Is a specific package about to change? Preview the plan and read its
+upstream changelog:
+
+\begin{lstlisting}[language=bashplus]
+up -n | grep -i firefox
+up --changelog firefox
+\end{lstlisting}
+
+\subsection{Pinning a package (Linux)}
+Add to \verb|~/.config/emerger/ignore.list|:
+
+\begin{lstlisting}[language=bashplus]
+# ~/.config/emerger/ignore.list
+nvidia-driver-535
+\end{lstlisting}
+
+Then, for \texttt{apt}:
+
+\begin{lstlisting}[language=bashplus]
+sudo apt-mark hold nvidia-driver-535
+\end{lstlisting}
+
+(pacman respects the file natively.)
+
+%-------------------------------------------------------
+\section{Security model}\label{sec:security}
+%-------------------------------------------------------
+
+\begin{itemize}
+ \item eMerger is a client-side tool. It has no network listener, no
+ daemon, no persistent background process.
+ \item It reads and writes only three locations: its config dir
+ (\verb|~/.config/emerger/|), its cache dir
+ (\verb|~/.cache/emerger/|) and its state dir
+ (\verb|~/.local/state/emerger/|), plus the global lock at
+ \verb|/tmp/emerger.lock| on Unix.
+ \item \texttt{sudo} credentials are cached by \texttt{sudo} itself, not
+ by eMerger. When the run ends, the cached credentials expire on
+ the usual \texttt{sudo} schedule (5 minutes by default).
+ \item On Windows, UAC elevation happens \emph{only} when a detected
+ manager actually needs admin. The elevation is logged.
+ \item Hooks and plugins run as the invoking user. They can do anything
+ the user can do - pin them to inodes you trust.
+ \item The only network calls eMerger itself makes are (a) the optional
+ weather widget via \texttt{wttr.in}, (b) \texttt{git pull} during
+ \texttt{--self-update}, (c) \texttt{fwupdmgr refresh} when
+ \texttt{--firmware} is active. Everything else is delegated to
+ the package manager you asked for.
+ \item There is no telemetry. \texttt{history.jsonl} stays on your
+ machine unless a hook you wrote ships it elsewhere.
+\end{itemize}
+
+%-------------------------------------------------------
+\section{Files and paths}\label{sec:files}
+%-------------------------------------------------------
+
+\paragraph{Linux and macOS}
+\begin{center}
+\begin{tabular}{@{}p{0.44\linewidth} p{0.50\linewidth}@{}}
+\toprule
+\textbf{Path} & \textbf{Purpose} \\
+\midrule
+\verb|~/.config/emerger/config.sh| & User defaults \\
+\verb|~/.config/emerger/profiles.d/| & User profiles \\
+\verb|~/.config/emerger/hooks/{pre,post}.d/| & Hook scripts \\
+\verb|~/.config/emerger/ignore.list| & Ignore list (pacman native) \\
+\verb|~/.config/emerger/managers.d/*.sh| & User manager plugins \\
+\verb|~/.cache/emerger/detected| & Detection cache \\
+\verb|~/.local/state/emerger/emerger.log| & Log (rotated at 2000 lines) \\
+\verb|~/.local/state/emerger/history.jsonl| & One JSON record per run \\
+\verb|~/.local/state/emerger/pkgs.before| & Package list snapshot (pre) \\
+\verb|~/.local/state/emerger/pkgs.after| & Package list snapshot (post) \\
+\verb|~/.local/state/emerger/pkgs.diff| & Last-run package diff \\
+\verb|~/.local/state/emerger/resume| & Resume cursor \\
+\verb|/tmp/emerger.lock| & Global lock (flock) \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+\paragraph{Windows}
+\begin{center}
+\begin{tabular}{@{}p{0.44\linewidth} p{0.50\linewidth}@{}}
+\toprule
+\textbf{Path} & \textbf{Purpose} \\
+\midrule
+\verb|%APPDATA%\emerger\config.ps1| & User defaults \\
+\verb|%APPDATA%\emerger\profiles.d\| & User profiles \\
+\verb|%APPDATA%\emerger\hooks\{pre,post}.d\| & Hook scripts \\
+\verb|%LOCALAPPDATA%\emerger\cache\| & Detection cache \\
+\verb|%LOCALAPPDATA%\emerger\state\emerger.log| & Log \\
+\verb|%LOCALAPPDATA%\emerger\state\history.jsonl| & Run history \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+%-------------------------------------------------------
+\section{Supported package managers}\label{sec:managers}
+%-------------------------------------------------------
+\begin{itemize}
+ \item \textbf{Linux, system (need sudo):} \texttt{pacman}, \texttt{apt},
+ \texttt{apt-get}, \texttt{dnf}, \texttt{yum}, \texttt{zypper},
+ \texttt{xbps}, \texttt{apk}, \texttt{eopkg}, \texttt{emerge},
+ \texttt{nixos-rebuild}, \texttt{fwupdmgr}, \texttt{snap}.
+ \item \textbf{Linux, AUR (no sudo):} \texttt{yay}, \texttt{paru}.
+ \item \textbf{Linux, user-space:} \texttt{flatpak}, \texttt{nix-env}.
+ \item \textbf{macOS:} \texttt{softwareupdate} (native),
+ \texttt{brew}, \texttt{brew --cask}, \texttt{mas}.
+ \item \textbf{Windows:} \texttt{winget}, \texttt{scoop}, \texttt{choco},
+ \texttt{PSWindowsUpdate}, \texttt{wsl --update}.
+ \item \textbf{Dev toolchains} (all platforms, opt-in with
+ \texttt{--dev}): \texttt{rustup}, \texttt{cargo install-update},
+ \texttt{npm}, \texttt{pnpm}, \texttt{pip}, \texttt{gem}.
+\end{itemize}
+
+%-------------------------------------------------------
+\section{Exit codes}\label{sec:exit}
+%-------------------------------------------------------
+\begin{center}
+\begin{tabular}{@{}cl@{}}
+\toprule
+\textbf{Code} & \textbf{Meaning} \\
+\midrule
+0 & Success \\
+1 & Runtime failure (sudo, lock, disk, interrupted) \\
+2 & Argument parsing error \\
+3 & One or more package managers returned non-zero \\
+4 & Reboot required (only with \texttt{--reboot-exit}) \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+Useful idioms:
+
+\begin{lstlisting}[language=bashplus]
+up -y -q || case $? in
+ 3) notify-send "eMerger: some managers failed" ;;
+ *) logger -t eMerger "fatal $?" ;;
+esac
+\end{lstlisting}
+
+\begin{lstlisting}[language=psplus]
+up -y -q
+if ($LASTEXITCODE -eq 3) { Write-Warning "Some managers failed" }
+\end{lstlisting}
+
+%-------------------------------------------------------
+\section{Troubleshooting}\label{sec:trouble}
+%-------------------------------------------------------
+
+\paragraph{``Another eMerger run is in progress''.}
+A stale \texttt{flock} on \verb|/tmp/emerger.lock|. Inspect \texttt{ps}
+for stragglers, then remove the lock file.
+
+\paragraph{A manager fails with exit code 3.}
+Run \texttt{up --errors} to tail the log, then \texttt{up --doctor} to
+inspect the environment. Most failures come from a manager's own health
+state (for example \texttt{dpkg} interrupted); run the native repair
+command (\texttt{sudo dpkg --configure -a}, \texttt{sudo pacman -Dk})
+before retrying.
+
+\paragraph{Nothing happens on scheduled runs.}
+Check \texttt{systemctl --user status emerger.timer} or the Windows Task
+Scheduler entry. On Unix the scheduled run always uses
+\texttt{-y -q -nl -ni}; enable \texttt{-v} in the unit for one cycle to
+see the output. On Linux systemd user timers do not fire if no session is
+open unless \texttt{loginctl enable-linger} has been run for the user.
+
+\paragraph{Emoji renders as boxes or question marks.}
+The terminal font does not include Unicode ranges eMerger uses. Either
+pass \texttt{--no-emoji}, set \texttt{ARG\_NO\_EMOJI=1} in
+\texttt{config.sh}, or switch to a Unicode-capable font.
+
+\paragraph{Spinner disappears on terminal resize.}
+Harmless. The live-log width recomputes every 120\,ms.
+
+\paragraph{\texttt{up --self-update} aborts with ``non fast-forward''.}
+You have local commits on top of \texttt{main}. Rebase or reset them
+manually; eMerger refuses to clobber local work.
+
+\paragraph{\texttt{notify-send} (Linux) never shows a notification.}
+No \texttt{DISPLAY} / \texttt{WAYLAND\_DISPLAY} in the environment
+(typical for cron). Use a systemd user timer instead: it inherits the
+session. Alternatively, write a \texttt{post.d} hook that notifies your
+own channel (Slack, Telegram, Discord, email).
+
+\paragraph{(Windows) ``up : The term `up' is not recognized''.}
+Your PowerShell profile did not load. Run \texttt{. \$PROFILE} or open
+a new window. If still missing, re-run \texttt{.\textbackslash setup.ps1}.
+
+\paragraph{(Windows) ``cannot be loaded because running scripts is disabled''.}
+\texttt{ExecutionPolicy} is \texttt{Restricted}. Lift it for the user:
+
+\begin{lstlisting}[language=psplus]
+Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
+\end{lstlisting}
+
+\paragraph{(Windows) Elevation fails silently.}
+You canceled the UAC prompt. The script logs \texttt{relaunching
+elevated} and exits: the elevated window does the actual work.
+
+\paragraph{Pacman keeps asking about ignored packages.}
+\texttt{ignore.list} is passed as \texttt{--ignore=}; \texttt{pacman}
+still prints the warning line. This is upstream behaviour, not an
+eMerger bug.
+
+\paragraph{``command not found: gum'' or ``whiptail''.}
+The interactive menu (\texttt{-i}) falls back to a plain-read loop when
+neither tool is installed. Install \texttt{gum}
+(\url{https://github.com/charmbracelet/gum}) for a nicer UI, or ignore
+the warning.
+
+%-------------------------------------------------------
+\section{Frequently asked questions}\label{sec:faq}
+%-------------------------------------------------------
+
+\paragraph{Is eMerger safe for production servers?}
+Yes, but use it in ``security only, unattended'' mode:
+\verb|up --profile server| (or its inlined equivalent). The
+\texttt{--reboot-exit} idiom lets your orchestrator decide when to
+reboot.
+
+\paragraph{Does \texttt{up} install packages I don't already have?}
+No. \texttt{up} only invokes managers that are already installed and
+only asks them to upgrade what is already installed. No new software is
+added unless a package upgrade pulls a new dependency in.
+
+\paragraph{Does \texttt{up} reboot my machine?}
+Only with \texttt{--reboot}. Without it, the summary prints a reboot
+advisory and the run exits normally.
+
+\paragraph{Can I use \texttt{up} without \texttt{sudo}?}
+Yes, in user-only mode: \verb|up --only flatpak,brew,npm,pip,cargo|
+(adjust to what you have). User-level managers never need elevation.
+
+\paragraph{Why is my terminal flashing during the parallel run?}
+Under \texttt{--parallel} each user-space manager streams its own
+output; when \texttt{-v} is not set, output is buffered and flushed at
+the end. If you want a calm terminal, drop \texttt{-v} or narrow the
+parallel set with \texttt{--only}.
+
+\paragraph{Can I use \texttt{up} inside a Docker image build?}
+Yes. Use \verb|up --only apt -y -qq| (or the relevant manager) and
+ignore the reboot advisory. Avoid \texttt{--firmware}, \texttt{--snapshot}
+and interactive flags.
+
+\paragraph{What happens if two \texttt{up} runs start at the same time?}
+The second one aborts with exit code 1 (``Another eMerger run is in
+progress''). The lock is a plain \texttt{flock} on
+\verb|/tmp/emerger.lock|.
+
+\paragraph{How do I completely remove eMerger?}
+Run the uninstaller, then:
+
+\begin{lstlisting}[language=bashplus]
+rm -rf ~/.config/emerger ~/.cache/emerger ~/.local/state/emerger
+rm -rf /path/to/eMerger # the repo itself
+\end{lstlisting}
+
+\paragraph{Where is my data?}
+All local, all in the paths listed in section \ref{sec:files}. Nothing
+leaves your machine unless a hook you wrote ships it.
+
+\paragraph{Can I contribute a new package manager?}
+Absolutely - see section \ref{sec:dev}. Most contributions are a 20-line
+dispatch branch plus a test case.
+
+%-------------------------------------------------------
+\section{Glossary}\label{sec:glossary}
+%-------------------------------------------------------
+
+\begin{itemize}
+ \item \textbf{Dry-run} - a simulation: the tool prints the commands it
+ would run without actually running them.
+ \item \textbf{Hook} - a user script that runs before (\texttt{pre.d})
+ or after (\texttt{post.d}) the update flow.
+ \item \textbf{Manager (package manager)} - software such as
+ \texttt{apt}, \texttt{pacman}, \texttt{brew}, \texttt{winget}
+ that installs, upgrades and removes programs on your machine.
+ \item \textbf{Parallel mode} - concurrent execution of user-space
+ managers. Safer than it sounds, because user-space managers do
+ not share a lock.
+ \item \textbf{Plugin} - a user-provided manager definition. Registers
+ a new manager without modifying the repository.
+ \item \textbf{Profile} - a named bundle of default flags, loaded via
+ \texttt{--profile NAME}.
+ \item \textbf{Resume cursor} - a file that records which managers
+ completed successfully, consumed by \texttt{--resume}.
+ \item \textbf{Snapshot} - a read-only filesystem checkpoint taken
+ before the upgrade. Linux only
+ (snapper/timeshift/btrfs).
+ \item \textbf{TUI} - Terminal User Interface. The interactive menu
+ (\texttt{-i}) on Unix.
+ \item \textbf{UAC} - User Account Control, the Windows elevation
+ prompt. eMerger relaunches itself elevated when needed.
+\end{itemize}
+
+%-------------------------------------------------------
+\section{Development}\label{sec:dev}
+%-------------------------------------------------------
+
+\subsection{Repository layout}
+
+\begin{lstlisting}[basicstyle=\ttfamily\footnotesize]
+eMerger/
+ src/
+ emerger.sh # Unix entry (Linux + macOS)
+ emerger.ps1 # Windows entry
+ lib/ # bash libs
+ pslib/ # PowerShell libs
+ logo/
+ share/profiles/ # shipped profiles (*.sh + *.ps1)
+ share/plugins/ # example plugins
+ completions/ # bash/zsh/fish completions
+ tests/ # bats tests
+ man/up.1 # man page
+ setup.sh setup.ps1
+ uninstall.sh uninstall.ps1
+ update.sh update.ps1
+ VERSION
+ .github/workflows/ci.yml
+\end{lstlisting}
+
+\subsection{Bash library modules (\texttt{src/lib/})}
+
+\begin{center}
+\begin{longtable}{@{}p{0.25\linewidth} p{0.70\linewidth}@{}}
+\toprule
+\textbf{File} & \textbf{Role} \\
+\midrule \endhead
+\texttt{ui.sh} & Colors, glyphs, spinner, box, live-log monitor \\
+\texttt{log.sh} & Structured logging (rotated at 2000 lines) \\
+\texttt{sys.sh} & OS / shell / battery / disk detection \\
+\texttt{run.sh} & Command runner (dry-run, retry, progress) \\
+\texttt{args.sh} & Argument parser and short-bundle expander \\
+\texttt{packages.sh} & Per-manager dispatcher \\
+\texttt{clean.sh} & Cache and trash cleaners \\
+\texttt{hooks.sh} & User hook runner \\
+\texttt{update.sh} & Self-update + cron / timer setup \\
+\texttt{notify.sh} & Desktop notifications \\
+\texttt{summary.sh} & Final banner, history persistence \\
+\texttt{tui.sh} & Interactive menu \\
+\texttt{lock.sh} & Global \texttt{flock} \\
+\texttt{retry.sh} & Retry on transient failures \\
+\texttt{reboot.sh} & Reboot-required detection \\
+\texttt{diff.sh} & Package snapshots + diff \\
+\texttt{disk.sh} & Disk-space precheck \\
+\texttt{snapshot.sh} & snapper / timeshift / btrfs \\
+\texttt{mirrors.sh} & Mirror rank / refresh \\
+\texttt{resume.sh} & Resume cursor \\
+\texttt{doctor.sh} & \texttt{--doctor} \\
+\texttt{changelog.sh} & \texttt{--changelog PKG} \\
+\texttt{report.sh} & Markdown export \\
+\texttt{wizard.sh} & First-run wizard \\
+\texttt{profiles.sh} & Profile loader \\
+\texttt{progress.sh} & Output summary + highlight \\
+\texttt{estimate.sh} & Step ETA from history \\
+\texttt{ignore.sh} & Ignore list loader \\
+\texttt{plugins.sh} & User plugin loader \\
+\texttt{metrics.sh} & Prometheus textfile export \\
+\texttt{help.txt} & \texttt{up --help} content \\
+\bottomrule
+\end{longtable}
+\end{center}
+
+\subsection{PowerShell library modules (\texttt{src/pslib/})}
+
+\begin{center}
+\begin{tabular}{@{}p{0.25\linewidth} p{0.70\linewidth}@{}}
+\toprule
+\textbf{File} & \textbf{Role} \\
+\midrule
+\texttt{UI.ps1} & Colors, glyphs, box, step \\
+\texttt{Log.ps1} & Structured logging \\
+\texttt{Sys.ps1} & OS, admin, battery, disk, UAC elevation \\
+\texttt{Args.ps1} & Arg parser (shift + regex) \\
+\texttt{Packages.ps1} & Manager dispatcher + \texttt{Run-Cmd} \\
+\texttt{Clean.ps1} & \texttt{\%TEMP\%} and Recycle Bin \\
+\texttt{Hooks.ps1} & \texttt{hooks\textbackslash \{pre,post\}.d\textbackslash *.ps1} \\
+\texttt{Update.ps1} & \texttt{git pull} self-update + Task Scheduler \\
+\texttt{Notify.ps1} & BurntToast (optional) \\
+\texttt{Summary.ps1} & Final box, history, reboot detection \\
+\texttt{Doctor.ps1} & \texttt{--doctor} \\
+\texttt{Profiles.ps1} & Profile loader \\
+\texttt{Help.txt} & Help text \\
+\bottomrule
+\end{tabular}
+\end{center}
+
+\subsection{Running tests}
+
+\begin{lstlisting}[language=bashplus]
+sudo apt-get install bats shellcheck
+bats tests/
+shellcheck -S error src/emerger.sh src/lib/*.sh setup.sh uninstall.sh update.sh
+\end{lstlisting}
+
+Both run on push via \texttt{.github/workflows/ci.yml}.
+
+\subsection{Adding a package manager}
+
+\paragraph{Unix} - edit \texttt{src/lib/packages.sh}:
+
+\begin{enumerate}
+ \item Add the name to \texttt{PKG\_MANAGERS}.
+ \item Add a branch in \texttt{\_pkg\_detect\_raw}.
+ \item Add a branch in \texttt{pkg\_run} with \texttt{run\_cmd} calls.
+ \item If the manager does not need sudo, exclude it from
+ \texttt{pkg\_needs\_sudo}.
+ \item Optional: add an emoji in \texttt{pkg\_icon}, a regex in
+ \texttt{progress.sh} to parse its output, a completion entry.
+\end{enumerate}
+
+\paragraph{Windows} - edit \texttt{src/pslib/Packages.ps1}:
+
+\begin{enumerate}
+ \item Add the name to \texttt{\$script:PKG\_MANAGERS} (or
+ \texttt{PKG\_DEV}).
+ \item Add a \texttt{Pkg-Detect} case.
+ \item Add a \texttt{Pkg-Run} case with \texttt{Run-Cmd} calls.
+ \item If it needs admin, add it to \texttt{Pkg-Need-Admin}.
+\end{enumerate}
+
+Both files are table-driven dispatchers; most contributions end up being
+a twenty-line patch plus a test case under \texttt{tests/}.
+
+\subsection{Contributing}
+Pull requests and issues are welcome on
+\href{https://github.com/MasterCruelty/eMerger}{GitHub}. Before opening
+a PR:
+
+\begin{itemize}
+ \item Run the test suite in \texttt{tests/} on the platform you
+ touched.
+ \item Attach \texttt{up --doctor} output and the relevant chunk of the
+ log when reporting bugs.
+ \item Keep feature parity in mind: when a feature makes sense on both
+ Bash and PowerShell, please implement it on both sides or flag
+ it for follow-up.
+\end{itemize}
+
+\vspace{1em}
+\hrule
+\vspace{0.5em}
+\noindent\textit{If your question is not answered here, open an issue on
+GitHub: we would rather improve the documentation than answer the same
+question twice.}
+
+\end{document}
diff --git a/man/up.1 b/man/up.1
new file mode 100644
index 0000000..6f772fd
--- /dev/null
+++ b/man/up.1
@@ -0,0 +1,85 @@
+.TH UP 1 "2026" "eMerger 2.0" "User Commands"
+.SH NAME
+up \- eMerger, one-command system updater
+.SH SYNOPSIS
+.B up
+[\fIoptions\fR]
+.SH DESCRIPTION
+eMerger detects installed package managers on the host and runs each of them
+in turn to refresh, upgrade and clean the system. It also offers optional
+cleanup of the user cache and trash.
+.SH OPTIONS
+.TP
+.B -h, --help
+Show help and exit.
+.TP
+.B -V, --version
+Show version and exit.
+.TP
+.B -i, --interactive
+Interactive menu (gum, whiptail, or plain fallback).
+.TP
+.B -n, --dry-run
+Print what would be done; do nothing.
+.TP
+.B -v, --verbose
+Stream package manager output live.
+.TP
+.B -q, --quiet
+Minimal output.
+.TP
+.B -y, --yes
+Assume "yes" on prompts.
+.TP
+.B --security
+Only security updates when the manager supports them.
+.TP
+.B --firmware
+Include firmware updates via fwupdmgr.
+.TP
+.B --dev
+Include dev toolchains (rustup, cargo, npm, pnpm, pip, gem).
+.TP
+.B --parallel
+Run independent user-space managers in parallel.
+.TP
+.B -nl, -ni, -nc, -nt
+Skip logo, system info, cache clean, trash clean.
+.TP
+.B -w, --weather
+Show the current weather.
+.TP
+.B -up
+Self-update eMerger via git.
+.TP
+.B -au
+Install a weekly auto-update (systemd user timer or cron).
+.TP
+.B -err
+Print logged errors.
+.TP
+.B --history
+Print a summary of recent runs.
+.SH FILES
+.TP
+.I ~/.config/emerger/config.sh
+Sourced before argument parsing.
+.TP
+.I ~/.config/emerger/hooks/pre.d/*.sh, post.d/*.sh
+User hook scripts.
+.TP
+.I ~/.local/state/emerger/emerger.log
+Log file.
+.TP
+.I ~/.local/state/emerger/history.jsonl
+One JSON record per run.
+.SH EXIT STATUS
+.TP
+0 success
+.TP
+1 runtime failure
+.TP
+2 argument parsing error
+.SH SEE ALSO
+.BR cron (8),
+.BR systemd.timer (5)
diff --git a/setup.ps1 b/setup.ps1
new file mode 100644
index 0000000..8fc134e
--- /dev/null
+++ b/setup.ps1
@@ -0,0 +1,69 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ Install eMerger on Windows: register the `up` function, ensure execution
+ policy allows it, scaffold %APPDATA%\emerger.
+#>
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$REPO = Split-Path -Parent $PSCommandPath
+. "$REPO\src\pslib\UI.ps1"
+. "$REPO\src\pslib\Sys.ps1"
+
+UI-Title 'eMerger setup (Windows)'
+
+# ExecutionPolicy fix.
+$ep = Get-ExecutionPolicy -Scope CurrentUser
+if ($ep -eq 'Restricted' -or $ep -eq 'Undefined') {
+ UI-Info "ExecutionPolicy is '$ep'; setting to RemoteSigned for CurrentUser."
+ try {
+ Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force
+ UI-Ok 'ExecutionPolicy updated.'
+ } catch {
+ UI-Warn "Could not change ExecutionPolicy: $($_.Exception.Message)"
+ }
+}
+
+# PowerShell profile: add `up` function.
+$profilePath = $PROFILE.CurrentUserAllHosts
+$profileDir = Split-Path $profilePath
+if (-not (Test-Path $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null }
+if (-not (Test-Path $profilePath)) { New-Item -ItemType File -Path $profilePath -Force | Out-Null }
+
+$entry = "$REPO\src\emerger.ps1"
+$existing = Get-Content $profilePath -Raw -ErrorAction SilentlyContinue
+if ($existing -and $existing -match 'emerger\.ps1') {
+ UI-Ok "'up' already registered in $profilePath"
+} else {
+ $block = @"
+
+# eMerger (https://github.com/MasterCruelty/eMerger)
+function up { & "$entry" @args }
+"@
+ Add-Content -Path $profilePath -Value $block
+ UI-Ok "Added 'up' function to $profilePath"
+}
+
+# Config + hooks + profiles skeleton.
+$cfg = Join-Path $env:APPDATA 'emerger'
+@('hooks\pre.d','hooks\post.d','profiles.d') | ForEach-Object {
+ $p = Join-Path $cfg $_
+ if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
+}
+
+$configFile = Join-Path $cfg 'config.ps1'
+if (-not (Test-Path $configFile)) {
+ @'
+# eMerger user config (Windows). Dot-sourced before argument parsing.
+# Uncomment to set defaults.
+
+# $script:ArgsGlobal.Dev = $true
+# $script:ArgsGlobal.NoTrash = $true
+# $script:ArgsGlobal.Security = $true
+'@ | Set-Content $configFile -Encoding UTF8
+ UI-Ok "Created $configFile"
+}
+
+UI-Info "Open a new PowerShell window (or: . `$PROFILE) and run: up --help"
diff --git a/setup.sh b/setup.sh
index f69b43b..0e232e1 100755
--- a/setup.sh
+++ b/setup.sh
@@ -1,34 +1,115 @@
-#!/bin/bash
-
-source src/utils/global.sh 2>>.log
-puts RED "Setup: starting"
-
-EXST=$(cat ~/.bashrc | grep -c "emerger.sh")
-if [[ $EXST -ne 0 ]]; then
- puts GREEN "Alias 'up' already exists. Use 'up' or run './src/emerger.sh'"
- source src/test/integrity_check.sh 2>>.log
- puts GREEN "Setup: completed $COOL"
-else
- source src/utils/cache_gen.sh > src/utils/.cache 2>>.log
- md5sum src/utils/.cache | cut -d " " -f1 > src/utils/.md5 2>>.log
- chmod 775 src/utils/.cache 2>>.log
- chmod 775 src/utils/.md5 2>>.log
-
- echo "alias up='bash $(pwd)/src/emerger.sh'" >> ~/.bashrc
- chmod +x src/emerger.sh 2>>.log
- puts GREEN "Alias 'up' added.\nUse 'up' or run './src/emerger.sh'"
- source src/test/integrity_check.sh 2>>.log
- puts GREEN "Setup: completed $COOL"
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+
+# shellcheck source=src/lib/ui.sh
+source "$REPO_DIR/src/lib/ui.sh"
+# shellcheck source=src/lib/sys.sh
+source "$REPO_DIR/src/lib/sys.sh"
+
+ui_title "eMerger setup"
+
+ALIAS_CMD="bash \"$REPO_DIR/src/emerger.sh\""
+
+_add_alias_bashzsh() {
+ local rc="$1"
+ touch "$rc"
+ if grep -q "emerger.sh" "$rc"; then
+ ui_ok "Alias already present in $rc"
+ return
+ fi
+ {
+ printf '\n# eMerger (https://github.com/MasterCruelty/eMerger)\n'
+ printf "alias up='%s'\n" "$ALIAS_CMD"
+ } >>"$rc"
+ ui_ok "Added 'up' alias to $rc"
+}
+
+_add_alias_fish() {
+ local conf="$HOME/.config/fish/config.fish"
+ mkdir -p "$(dirname "$conf")"
+ touch "$conf"
+ if grep -q "emerger.sh" "$conf"; then
+ ui_ok "Alias already present in $conf"
+ return
+ fi
+ {
+ printf '\n# eMerger\n'
+ printf "alias up '%s'\n" "$ALIAS_CMD"
+ } >>"$conf"
+ ui_ok "Added 'up' alias to $conf"
+}
+
+installed_any=0
+# macOS default shell is zsh (since Catalina); prioritize accordingly.
+if sys_is_macos; then
+ _add_alias_bashzsh "${ZDOTDIR:-$HOME}/.zshrc"; installed_any=1
+ [[ -f "$HOME/.bashrc" ]] && { _add_alias_bashzsh "$HOME/.bashrc"; }
+elif [[ -f "$HOME/.bashrc" ]] || [[ $(basename "${SHELL:-}") == bash ]]; then
+ _add_alias_bashzsh "$HOME/.bashrc"; installed_any=1
+fi
+if [[ -f "$HOME/.zshrc" ]] || [[ $(basename "${SHELL:-}") == zsh ]]; then
+ _add_alias_bashzsh "${ZDOTDIR:-$HOME}/.zshrc"; installed_any=1
+fi
+if [[ -f "$HOME/.config/fish/config.fish" ]] || [[ $(basename "${SHELL:-}") == fish ]]; then
+ _add_alias_fish; installed_any=1
fi
+if (( ! installed_any )); then
+ _add_alias_bashzsh "$(sys_shell_rc)"
+fi
+
+chmod +x "$REPO_DIR/src/emerger.sh"
+
+# Install shell completions if the user's dirs exist.
+install_completion() {
+ local src="$1" dest="$2"
+ [[ -f $src ]] || return 0
+ mkdir -p "$(dirname "$dest")"
+ if [[ -e $dest ]] && ! diff -q "$src" "$dest" >/dev/null 2>&1; then
+ cp -f "$dest" "$dest.backup-$(date +%s)"
+ fi
+ install -m 0644 "$src" "$dest"
+ ui_ok "Installed completion: $dest"
+}
-# Open a new terminal
-TERMINAL=$(cat src/utils/.cache | head -n 2 | tail -n 1)
-if [[ $TERMINAL == "unknown" ]]; then
- exec bash
- exit 0
-else
- read -p "$(echo -e ${RED}Press enter, this process will be killed${NORMAL})"
-
- $TERMINAL 2>>.log
- kill -9 $PPID
+bash_comp="${BASH_COMPLETION_USER_DIR:-$HOME/.local/share/bash-completion/completions}"
+zsh_comp="$HOME/.zsh/completions"
+fish_comp="$HOME/.config/fish/completions"
+# On macOS with Homebrew, prefer brew's own completion dirs.
+if brew_prefix=$(sys_brew_prefix); then
+ [[ -d "$brew_prefix/etc/bash_completion.d" ]] && bash_comp="$brew_prefix/etc/bash_completion.d"
+ [[ -d "$brew_prefix/share/zsh/site-functions" ]] && zsh_comp="$brew_prefix/share/zsh/site-functions"
fi
+
+install_completion "$REPO_DIR/completions/up.bash" "$bash_comp/up"
+install_completion "$REPO_DIR/completions/_up" "$zsh_comp/_up"
+install_completion "$REPO_DIR/completions/up.fish" "$fish_comp/up.fish"
+
+# Default config skeleton.
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/emerger"
+mkdir -p "$CONFIG_DIR/hooks/pre.d" "$CONFIG_DIR/hooks/post.d" "$CONFIG_DIR/profiles.d"
+if [[ ! -f "$CONFIG_DIR/config.sh" ]]; then
+ cat >"$CONFIG_DIR/config.sh" <<'EOF'
+# eMerger user config. Sourced before argument parsing.
+# Uncomment to change defaults.
+
+# ARG_DEV=1 # always include dev toolchains
+# ARG_WEATHER=1 # always show weather
+# ARG_NO_TRASH=1 # never touch trash
+# DISK_MIN_FREE_MB=2048
+# QUIET_HOURS="23:00-07:00" # skip when timer fires inside this window
+EOF
+ ui_ok "Created $CONFIG_DIR/config.sh"
+fi
+if [[ ! -f "$CONFIG_DIR/ignore.list" ]]; then
+ cat >"$CONFIG_DIR/ignore.list" <<'EOF'
+# eMerger ignore list: one package name per line. Comments with #.
+# Note: pacman honors this natively via --ignore.
+# For apt use: sudo apt-mark hold
+# For dnf use: sudo dnf versionlock add
+EOF
+ ui_ok "Created $CONFIG_DIR/ignore.list"
+fi
+
+ui_info "Open a new shell (or 'source ~/.bashrc') and run: up --help"
diff --git a/share/plugins/example.sh b/share/plugins/example.sh
new file mode 100644
index 0000000..4576f9b
--- /dev/null
+++ b/share/plugins/example.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Example eMerger plugin. Copy to ~/.config/emerger/managers.d/.sh
+# and edit. The slug below becomes the manager name shown by eMerger.
+
+PM_PLUGIN_SLUG=example
+
+pm_example_detect() {
+ # Return 0 if the tool is installed on this system.
+ command -v example-tool >/dev/null 2>&1
+}
+
+pm_example_needs_sudo() { return 1; } # remove or flip to 0 if sudo is needed
+pm_example_parallel() { return 0; } # safe to run concurrently with others
+pm_example_dev() { return 1; } # return 0 to gate under --dev
+
+pm_example_icon() { printf '\xf0\x9f\x94\x8c'; } # plug emoji
+
+pm_example_run() {
+ # Use run_cmd so --dry-run, --verbose, retry, logging all work.
+ run_cmd "example refresh" example-tool refresh || return 1
+ run_cmd "example upgrade" example-tool upgrade -y || return 1
+ return 0
+}
diff --git a/share/profiles/home.ps1 b/share/profiles/home.ps1
new file mode 100644
index 0000000..9215b5d
--- /dev/null
+++ b/share/profiles/home.ps1
@@ -0,0 +1,2 @@
+# description: PC fisso a casa - tutto, dev toolchains incluse
+$script:ArgsGlobal.Dev = $true
diff --git a/share/profiles/home.sh b/share/profiles/home.sh
new file mode 100644
index 0000000..fdca3cf
--- /dev/null
+++ b/share/profiles/home.sh
@@ -0,0 +1,5 @@
+# description: PC fisso a casa - tutto, in parallelo, anche firmware e dev
+ARG_DEV=1
+ARG_FIRMWARE=1
+ARG_PARALLEL=1
+ARG_WEATHER=1
diff --git a/share/profiles/safe.ps1 b/share/profiles/safe.ps1
new file mode 100644
index 0000000..63db271
--- /dev/null
+++ b/share/profiles/safe.ps1
@@ -0,0 +1,5 @@
+# description: pre-presentazione - security only, niente cache/trash
+$script:ArgsGlobal.Security = $true
+$script:ArgsGlobal.Yes = $true
+$script:ArgsGlobal.NoCache = $true
+$script:ArgsGlobal.NoTrash = $true
diff --git a/share/profiles/safe.sh b/share/profiles/safe.sh
new file mode 100644
index 0000000..d251954
--- /dev/null
+++ b/share/profiles/safe.sh
@@ -0,0 +1,6 @@
+# description: pre-presentazione - solo security, niente flatpak/snap pesanti
+ARG_SECURITY=1
+ARG_YES=1
+ARG_NO_CACHE=1
+ARG_NO_TRASH=1
+ARG_NO_FIRMWARE=1
diff --git a/share/profiles/server.ps1 b/share/profiles/server.ps1
new file mode 100644
index 0000000..29b794c
--- /dev/null
+++ b/share/profiles/server.ps1
@@ -0,0 +1,8 @@
+# description: headless / unattended - security, quiet, nessun prompt
+$script:ArgsGlobal.Security = $true
+$script:ArgsGlobal.Yes = $true
+$script:ArgsGlobal.NoLogo = $true
+$script:ArgsGlobal.NoInfo = $true
+$script:ArgsGlobal.NoCache = $true
+$script:ArgsGlobal.NoTrash = $true
+$script:QUIET_LEVEL = 2
diff --git a/share/profiles/server.sh b/share/profiles/server.sh
new file mode 100644
index 0000000..1a59345
--- /dev/null
+++ b/share/profiles/server.sh
@@ -0,0 +1,10 @@
+# description: headless/unattended - quiet, security, niente prompt, niente UI
+ARG_SECURITY=1
+ARG_YES=1
+ARG_QUIET=1
+QUIET_LEVEL=2
+ARG_NO_LOGO=1
+ARG_NO_INFO=1
+ARG_NO_CACHE=1
+ARG_NO_TRASH=1
+ARG_NO_FIRMWARE=1
diff --git a/share/profiles/work.ps1 b/share/profiles/work.ps1
new file mode 100644
index 0000000..72470ff
--- /dev/null
+++ b/share/profiles/work.ps1
@@ -0,0 +1,5 @@
+# description: laptop al lavoro - security, unattended, niente cache/trash
+$script:ArgsGlobal.Security = $true
+$script:ArgsGlobal.Yes = $true
+$script:ArgsGlobal.NoCache = $true
+$script:ArgsGlobal.NoTrash = $true
diff --git a/share/profiles/work.sh b/share/profiles/work.sh
new file mode 100644
index 0000000..9808bb5
--- /dev/null
+++ b/share/profiles/work.sh
@@ -0,0 +1,6 @@
+# description: laptop al lavoro - solo security, niente firmware/AUR, riavvio mai
+ARG_SECURITY=1
+ARG_YES=1
+ARG_NO_FIRMWARE=1
+ARG_NO_CACHE=1
+ARG_NO_TRASH=1
diff --git a/src/emerger.ps1 b/src/emerger.ps1
new file mode 100644
index 0000000..8c30f22
--- /dev/null
+++ b/src/emerger.ps1
@@ -0,0 +1,178 @@
+#Requires -Version 5.1
+[CmdletBinding()]
+param(
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$Arguments
+)
+
+$ErrorActionPreference = 'Stop'
+
+# Paths.
+$script:EMERGER_SRC = $PSScriptRoot
+$script:EMERGER_ROOT = Split-Path -Parent $script:EMERGER_SRC
+$script:EMERGER_LIB = Join-Path $script:EMERGER_SRC 'pslib'
+$versionFile = Join-Path $script:EMERGER_ROOT 'VERSION'
+$script:EMERGER_VERSION = if (Test-Path $versionFile) { (Get-Content $versionFile -Raw).Trim() } else { '0.0.0' }
+
+$script:EMERGER_CONFIG = Join-Path $env:APPDATA 'emerger'
+$script:EMERGER_CACHE = Join-Path $env:LOCALAPPDATA 'emerger\cache'
+$script:EMERGER_STATE = Join-Path $env:LOCALAPPDATA 'emerger\state'
+foreach ($d in @($script:EMERGER_CONFIG, $script:EMERGER_CACHE, $script:EMERGER_STATE)) {
+ if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
+}
+
+# Load libs.
+. (Join-Path $script:EMERGER_LIB 'UI.ps1')
+. (Join-Path $script:EMERGER_LIB 'Log.ps1')
+. (Join-Path $script:EMERGER_LIB 'Sys.ps1')
+. (Join-Path $script:EMERGER_LIB 'Args.ps1')
+. (Join-Path $script:EMERGER_LIB 'Packages.ps1')
+. (Join-Path $script:EMERGER_LIB 'Clean.ps1')
+. (Join-Path $script:EMERGER_LIB 'Hooks.ps1')
+. (Join-Path $script:EMERGER_LIB 'Update.ps1')
+. (Join-Path $script:EMERGER_LIB 'Notify.ps1')
+. (Join-Path $script:EMERGER_LIB 'Summary.ps1')
+. (Join-Path $script:EMERGER_LIB 'Doctor.ps1')
+. (Join-Path $script:EMERGER_LIB 'Profiles.ps1')
+
+# User config.
+$userCfg = Join-Path $script:EMERGER_CONFIG 'config.ps1'
+if (Test-Path $userCfg) { . $userCfg }
+
+# Profile prescan (profile sets defaults, CLI wins).
+$profileName = Args-Prescan-Profile $Arguments
+if ($profileName) { Load-Profile $profileName }
+
+# Parse CLI flags.
+$script:ArgsGlobal = Parse-Args $Arguments
+$script:Summary = New-Object System.Collections.Generic.List[PSCustomObject]
+
+Log-Init
+
+# Subcommand-style short-circuits.
+if ($script:ArgsGlobal.Help) { Get-Content (Join-Path $script:EMERGER_LIB 'Help.txt') ; exit 0 }
+if ($script:ArgsGlobal.Version) { Write-Host "eMerger $script:EMERGER_VERSION"; exit 0 }
+if ($script:ArgsGlobal.Errors) { Show-Errors; exit 0 }
+if ($script:ArgsGlobal.History) { Show-History; exit 0 }
+if ($script:ArgsGlobal.Doctor) { $rc = Doctor-Run; exit $rc }
+if ($script:ArgsGlobal.SelfUpdate) { $rc = Self-Update $script:EMERGER_ROOT; exit $rc }
+if ($script:ArgsGlobal.AutoUpdate) { $rc = Setup-Task $script:EMERGER_ROOT; exit $rc }
+if ($script:ArgsGlobal.ListProfiles) { List-Profiles; exit 0 }
+if ($script:ArgsGlobal.Metrics) {
+ $hist = Join-Path $script:EMERGER_STATE 'history.jsonl'
+ if (-not (Test-Path $hist)) { UI-Err 'No history yet'; exit 1 }
+ $last = Get-Content $hist -Tail 1 | ConvertFrom-Json
+ $epoch = [int64](([DateTime]$last.ts).ToUniversalTime() - [DateTime]'1970-01-01').TotalSeconds
+ $tmp = "$($script:ArgsGlobal.Metrics).tmp"
+ $out = @()
+ $out += "# HELP emerger_last_run_timestamp_seconds Unix timestamp of the last eMerger run"
+ $out += "# TYPE emerger_last_run_timestamp_seconds gauge"
+ $out += "emerger_last_run_timestamp_seconds $epoch"
+ $out += "# HELP emerger_last_run_duration_seconds Duration of the last run"
+ $out += "# TYPE emerger_last_run_duration_seconds gauge"
+ $out += "emerger_last_run_duration_seconds $($last.duration)"
+ $out += "# HELP emerger_last_run_errors Manager failures in the last run"
+ $out += "# TYPE emerger_last_run_errors gauge"
+ $out += "emerger_last_run_errors $($last.errors)"
+ $out += "# HELP emerger_reboot_required Whether a reboot is pending (0/1)"
+ $out += "# TYPE emerger_reboot_required gauge"
+ $out += "emerger_reboot_required $($last.reboot)"
+ $out += "# HELP emerger_manager_ok Per-manager success (1=ok,0=fail)"
+ $out += "# TYPE emerger_manager_ok gauge"
+ foreach ($m in $last.managers) {
+ $v = if ($m.result -eq 'ok') { 1 } else { 0 }
+ $out += "emerger_manager_ok{manager=`"$($m.name)`"} $v"
+ }
+ $out | Set-Content -Path $tmp
+ Move-Item -Force $tmp $script:ArgsGlobal.Metrics
+ UI-Ok "metrics written to $($script:ArgsGlobal.Metrics)"
+ exit 0
+}
+if ($script:ArgsGlobal.RebuildCache) {
+ Remove-Item $script:EMERGER_CACHE -Recurse -Force -ErrorAction SilentlyContinue
+ New-Item -ItemType Directory -Path $script:EMERGER_CACHE -Force | Out-Null
+ UI-Ok 'Detection cache cleared'
+}
+if ($script:ArgsGlobal.Reboot) {
+ if (Reboot-Pending) {
+ UI-Warn 'Rebooting now.'
+ Restart-Computer -Force
+ exit 0
+ } else { UI-Ok 'No reboot needed.'; exit 0 }
+}
+
+# Elevation: relaunch elevated if any detected manager needs it.
+if (Pkg-Need-Admin-Any -IncludeDev:$script:ArgsGlobal.Dev) {
+ if (-not (Sys-IsAdmin) -and -not $script:ArgsGlobal.DryRun) {
+ UI-Warn 'Admin privileges required by one of the detected managers. Relaunching elevatedโฆ'
+ Log-Info 'relaunching elevated'
+ Sys-Start-Elevated (Join-Path $script:EMERGER_SRC 'emerger.ps1') $Arguments
+ exit 0
+ }
+}
+
+# Battery check.
+if (Sys-On-Battery) {
+ $pct = Sys-Battery-Percent
+ if ($pct -lt 20 -and -not $script:ArgsGlobal.Yes) {
+ UI-Warn "On battery at ${pct}%. Updates are I/O heavy."
+ $a = Read-Host ' Continue anyway? [y/N]'
+ if ($a -notmatch '^[Yy]') { UI-Muted 'Aborted.'; exit 0 }
+ }
+}
+
+# Main flow.
+$start = [DateTime]::UtcNow
+
+if ($script:ArgsGlobal.Json) {
+ $script:ArgsGlobal.NoLogo = $true; $script:ArgsGlobal.NoInfo = $true; $script:QUIET_LEVEL = 3
+}
+if (-not $script:ArgsGlobal.NoLogo -and $script:QUIET_LEVEL -lt 1) {
+ UI-Logo (Join-Path $script:EMERGER_SRC 'logo\logo.txt')
+}
+if ($script:QUIET_LEVEL -lt 2) {
+ if (-not $script:ArgsGlobal.NoInfo) {
+ UI-Muted ("{0} $(UI-Glyph dot) {1} $(UI-Glyph dot) {2}" -f (Sys-OS), (Sys-Arch), (Get-Date -Format 'yyyy-MM-dd HH:mm'))
+ }
+ UI-Muted ("eMerger v$script:EMERGER_VERSION $(UI-Glyph dot) github.com/MasterCruelty/eMerger")
+}
+
+Hooks-Run 'pre'
+
+$detected = Pkg-Detect-All -IncludeDev:$script:ArgsGlobal.Dev
+$total = $detected.Count
+if ($total -eq 0) { UI-Warn 'No supported package managers detected.' }
+
+$onlyList = @(); if ($script:ArgsGlobal.Only) { $onlyList = $script:ArgsGlobal.Only -split ',' | ForEach-Object { $_.Trim() } }
+$exceptList = @(); if ($script:ArgsGlobal.Except) { $exceptList = $script:ArgsGlobal.Except -split ',' | ForEach-Object { $_.Trim() } }
+
+$filtered = @()
+foreach ($m in $detected) {
+ if ($onlyList.Count -gt 0 -and ($onlyList -notcontains $m)) { continue }
+ if ($exceptList.Count -gt 0 -and ($exceptList -contains $m)) { UI-Muted "skip $m (--except)"; continue }
+ $filtered += $m
+}
+$detected = $filtered
+$total = $detected.Count
+
+$i = 0
+foreach ($m in $detected) {
+ $i++
+ UI-Step $i $total $m
+ $ok = Pkg-Run $m
+ $script:Summary.Add([pscustomobject]@{ Name = $m; Result = $(if ($ok) { 'ok' } else { 'fail' }) })
+}
+
+if (-not $script:ArgsGlobal.NoCache) { Clean-Temp }
+if (-not $script:ArgsGlobal.NoTrash) { Clean-RecycleBin }
+
+Hooks-Run 'post'
+
+$duration = [int]([DateTime]::UtcNow - $start).TotalSeconds
+Summary-Print $duration
+Notify-Send-Result
+
+$errors = @($script:Summary | Where-Object { $_.Result -eq 'fail' }).Count
+if ($errors -gt 0) { exit 3 }
+if ($script:ArgsGlobal.RebootExit -and (Reboot-Pending)) { exit 4 }
+exit 0
diff --git a/src/emerger.sh b/src/emerger.sh
index 4703ff7..7b8f678 100755
--- a/src/emerger.sh
+++ b/src/emerger.sh
@@ -1,114 +1,328 @@
-#!/bin/bash
+#!/usr/bin/env bash
+# eMerger - one-command system updater.
+# Entry point: arg parsing + orchestration. Logic lives in src/lib/.
-SRC=$(dirname "$(readlink -f "$0")")
-ROOT=${SRC::-3}
-source $SRC/utils/global.sh
+set -Eeuo pipefail
+IFS=$'\n\t'
-# Create .log if it doesn't exist
-if [[ ! -f $ROOT.log ]]; then
- printf "" > $ROOT.log
-fi
+EMERGER_SRC=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
+EMERGER_ROOT=$(dirname "$EMERGER_SRC")
+EMERGER_LIB="$EMERGER_SRC/lib"
+EMERGER_VERSION=$(cat "$EMERGER_ROOT/VERSION" 2>/dev/null || echo "0.0.0")
+
+: "${XDG_CACHE_HOME:=$HOME/.cache}"
+: "${XDG_CONFIG_HOME:=$HOME/.config}"
+: "${XDG_STATE_HOME:=$HOME/.local/state}"
+EMERGER_CACHE="$XDG_CACHE_HOME/emerger"
+EMERGER_CONFIG="$XDG_CONFIG_HOME/emerger"
+EMERGER_STATE="$XDG_STATE_HOME/emerger"
+mkdir -p "$EMERGER_CACHE" "$EMERGER_STATE"
-# Clear .log if it gets too long (keep only the last 256 lines)
-# Given no errors, the max file size is 7KB
-if [[ $(wc -l < $ROOT.log) -gt 256 ]]; then
- printf "$(tail -n 256 $ROOT.log)\n" > $ROOT.log
+# shellcheck source=lib/ui.sh
+source "$EMERGER_LIB/ui.sh"
+# shellcheck source=lib/log.sh
+source "$EMERGER_LIB/log.sh"
+# shellcheck source=lib/sys.sh
+source "$EMERGER_LIB/sys.sh"
+# shellcheck source=lib/progress.sh
+source "$EMERGER_LIB/progress.sh"
+# shellcheck source=lib/estimate.sh
+source "$EMERGER_LIB/estimate.sh"
+# shellcheck source=lib/retry.sh
+source "$EMERGER_LIB/retry.sh"
+# shellcheck source=lib/run.sh
+source "$EMERGER_LIB/run.sh"
+# shellcheck source=lib/args.sh
+source "$EMERGER_LIB/args.sh"
+# shellcheck source=lib/ignore.sh
+source "$EMERGER_LIB/ignore.sh"
+# shellcheck source=lib/packages.sh
+source "$EMERGER_LIB/packages.sh"
+# shellcheck source=lib/clean.sh
+source "$EMERGER_LIB/clean.sh"
+# shellcheck source=lib/hooks.sh
+source "$EMERGER_LIB/hooks.sh"
+# shellcheck source=lib/update.sh
+source "$EMERGER_LIB/update.sh"
+# shellcheck source=lib/notify.sh
+source "$EMERGER_LIB/notify.sh"
+# shellcheck source=lib/reboot.sh
+source "$EMERGER_LIB/reboot.sh"
+# shellcheck source=lib/diff.sh
+source "$EMERGER_LIB/diff.sh"
+# shellcheck source=lib/disk.sh
+source "$EMERGER_LIB/disk.sh"
+# shellcheck source=lib/snapshot.sh
+source "$EMERGER_LIB/snapshot.sh"
+# shellcheck source=lib/mirrors.sh
+source "$EMERGER_LIB/mirrors.sh"
+# shellcheck source=lib/resume.sh
+source "$EMERGER_LIB/resume.sh"
+# shellcheck source=lib/lock.sh
+source "$EMERGER_LIB/lock.sh"
+# shellcheck source=lib/doctor.sh
+source "$EMERGER_LIB/doctor.sh"
+# shellcheck source=lib/changelog.sh
+source "$EMERGER_LIB/changelog.sh"
+# shellcheck source=lib/report.sh
+source "$EMERGER_LIB/report.sh"
+# shellcheck source=lib/wizard.sh
+source "$EMERGER_LIB/wizard.sh"
+# shellcheck source=lib/profiles.sh
+source "$EMERGER_LIB/profiles.sh"
+# shellcheck source=lib/summary.sh
+source "$EMERGER_LIB/summary.sh"
+# shellcheck source=lib/tui.sh
+source "$EMERGER_LIB/tui.sh"
+# shellcheck source=lib/plugins.sh
+source "$EMERGER_LIB/plugins.sh"
+# shellcheck source=lib/metrics.sh
+source "$EMERGER_LIB/metrics.sh"
+
+# 1) User global config
+if [[ -f "$EMERGER_CONFIG/config.sh" ]]; then
+ # shellcheck disable=SC1091
+ source "$EMERGER_CONFIG/config.sh"
fi
-# If the script got interrupted, history still exists and has to be cleaned
-echo -n "" > $SRC/.hist
+# 2) Profile (if --profile seen in argv) - loaded BEFORE explicit flags so
+# CLI overrides stay authoritative.
+args_prescan_profile "$@"
+[[ -n ${PROFILE_PRELOAD:-} ]] && profile_load "$PROFILE_PRELOAD"
-ARGC=$#
-ARGV=$@
+# 3) Explicit CLI flags
+args_parse "$@"
-# Check if arguments passed exist
-if [[ $ARGC -gt 0 ]]; then
- source $SRC/test/argument_check.sh $ARGV
+# Quiet-hours gating (user can set QUIET_HOURS="22:00-07:00" in config).
+if [[ -n ${QUIET_HOURS:-} ]] && (( ARG_YES )); then
+ _now=$(date +%H%M)
+ _from=$(printf '%s' "${QUIET_HOURS%%-*}" | tr -d :)
+ _to=$(printf '%s' "${QUIET_HOURS##*-}" | tr -d :)
+ if (( _from < _to )); then
+ (( _now >= _from && _now < _to )) && { echo "quiet hours, skipping"; exit 0; }
+ else
+ (( _now >= _from || _now < _to )) && { echo "quiet hours, skipping"; exit 0; }
+ fi
fi
-if [[ $ARGV =~ "-help" ]]; then
- cat $SRC/utils/help
-elif [[ $ARGV =~ "-err" ]]; then
- if [[ $(grep -cv "[0-9]*/[0-9]*/[0-9]* [0-9]*:[0-9]*:[0-9]*:[0-9]*" $ROOT.log) -gt 0 ]]; then
- puts RED "Errors found\nOpen .log in $ROOT to see what's wrong"
- else
- puts GREEN "No errors found"
+SUDO_KEEPALIVE_PID=0
+_cleanup() {
+ ui_monitor_stop
+ if (( SUDO_KEEPALIVE_PID > 0 )); then
+ kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
fi
-elif [[ $ARGV =~ "-up" ]]; then
- source ${ROOT}update.sh $ROOT
-elif [[ $ARGV =~ "-xyzzy" ]]; then
- puts NC "Let's keep its memory alive"
-else
- if [[ -f "$SRC/utils/.cache" && ! $ARGV =~ "-rc" ]]; then
- HASH=$(md5sum "$SRC/utils/.cache" | cut -d " " -f1)
- if [[ $HASH != $(cat $SRC/utils/.md5) ]]; then
- md5sum $SRC/utils/.cache | cut -d " " -f1 > $SRC/utils/.md5
- fi
+ lock_release
+}
+trap _cleanup EXIT INT TERM
+
+log_init
+
+# Subcommand-style short-circuits.
+(( ARG_HELP )) && { cat "$EMERGER_LIB/help.txt"; exit 0; }
+(( ARG_VERSION )) && { printf 'eMerger %s\n' "$EMERGER_VERSION"; exit 0; }
+(( ARG_ERR )) && { show_errors; exit 0; }
+(( ARG_HISTORY )) && { show_history; exit 0; }
+(( ARG_CHANGED )) && { diff_show; exit 0; }
+(( ARG_DOCTOR )) && { doctor_run; exit $?; }
+(( ARG_LIST_PROFILES )) && { profile_list; exit 0; }
+[[ -n $ARG_CHANGELOG ]] && { changelog_show "$ARG_CHANGELOG"; exit $?; }
+(( ARG_UP )) && { self_update "$EMERGER_ROOT"; exit $?; }
+(( ARG_AU )) && { setup_cron "$EMERGER_ROOT"; exit $?; }
+(( ARG_RC )) && { rm -rf "$EMERGER_CACHE"; mkdir -p "$EMERGER_CACHE"; ui_ok "Detection cache cleared"; }
+[[ -n $ARG_REPORT ]] && { report_export "$ARG_REPORT"; exit $?; }
+[[ -n $ARG_METRICS ]] && { metrics_export "$ARG_METRICS"; exit $?; }
+(( ARG_ROLLBACK )) && { snapshot_rollback; exit $?; }
+if (( ARG_REBOOT )); then
+ reboot_check
+ if (( REBOOT_NEEDED )); then
+ ui_warn "Rebooting now."
+ exec sudo reboot
else
- $SRC/utils/cache_gen.sh > $SRC/utils/.cache
- md5sum $SRC/utils/.cache | cut -d " " -f1 > $SRC/utils/.md5
- chmod 775 $SRC/utils/.cache
+ ui_ok "No reboot needed."; exit 0
fi
- chmod 775 $SRC/utils/.md5
+fi
+(( ARG_INTERACTIVE )) && tui_menu
- # Logo
- if [[ ! $ARGV =~ "-nl" ]]; then
- if [[ $(stty size | awk '{print $2}') -ge 74 ]]; then
- puts LOGO "$(cat $SRC/utils/.logo)"
- echo "$NORMAL$LOGO$(cat $SRC/utils/.logo)$NORMAL\n" >> $SRC/.hist
- fi
- puts LOGO "Contribute @ https://github.com/MasterCruelty/eMerger $WHALE"
+# First-run wizard (only if interactive and no config).
+[[ ! -f $EMERGER_CONFIG/.wizard-done ]] && wizard_maybe_run
+
+# Global lock.
+if ! lock_acquire; then exit 1; fi
+
+ignore_load
+plugins_load
+resume_load
+# Build --only / --except filters (comma-separated manager names).
+_arg_list_has() {
+ local list="$1" item="$2" x
+ IFS=',' read -r -a __al <<<"$list"
+ for x in "${__al[@]}"; do
+ [[ $x == "$item" ]] && return 0
+ done
+ return 1
+}
+
+main() {
+ local start=$SECONDS
+
+ if (( ARG_JSON )); then
+ ARG_NO_LOGO=1; ARG_NO_INFO=1; QUIET_LEVEL=3
fi
- # System informations
- if [[ ! $ARGV =~ "-ni" ]]; then
- if [[ -f "/etc/os-release" ]]; then
- NAME=$(cat /etc/os-release | head -n $(echo $(grep -n "PRETTY_NAME" /etc/os-release) | cut -c 1) | tail -n 1 | cut -c 13-)
- puts LOGO "${NAME}"
- else
- puts LOGO "$(uname -rs)"
+ if (( ! ARG_NO_LOGO )) && (( ! ARG_QUIET )); then
+ ui_print_logo "$EMERGER_SRC/logo/logo.txt"
+ fi
+
+ if (( QUIET_LEVEL < 2 )); then
+ if (( ! ARG_NO_INFO )); then
+ ui_muted "$(sys_distro) $(ui_glyph dot) $(uname -m) $(ui_glyph dot) $(date '+%Y-%m-%d %H:%M')"
fi
+ ui_muted "eMerger v$EMERGER_VERSION $(ui_glyph dot) github.com/MasterCruelty/eMerger"
fi
- # Weather
- if [[ $ARGV =~ "-w" ]]; then
- # Using wttr.in to show the weather using the following arguments:
- # %l = location; %c = weather emoji; %t = actual temp; %w = wind km/h; %m = Moon phase
- puts LOGO "$(curl -s wttr.in/?format="%l:+%c+%t+%w+%m")"
+ if (( ARG_WEATHER )) && sys_has curl; then
+ local w
+ w=$(curl -sS --max-time 3 'https://wttr.in/?format=%l:+%c+%t+%w+%m' 2>/dev/null || true)
+ [[ -n $w ]] && ui_muted "$w"
fi
- # `tail -n +3` skips the first two lines
- # ITER keeps track of iterations ('tail -n 3', so ITER='3-1')
- ITER=2
- for LINE in $(cat $SRC/utils/.cache | tail -n +3); do
- ITER=$(($ITER + 1))
- if [[ $LINE == "utils/cache" && $ARGV =~ "-nc" ]]; then
- continue
+ ignore_advisory
+
+ # Battery safety.
+ if sys_on_battery; then
+ local pct; pct=$(sys_battery_percent)
+ if (( pct < 20 )) && (( ! ARG_YES )); then
+ ui_warn "On battery at ${pct}%. Updates are I/O heavy."
+ local ans
+ read -r -p " Continue anyway? [y/N]: " ans || ans=n
+ [[ $ans == [yY]* ]] || { ui_muted "Aborted."; exit 0; }
+ fi
+ fi
+
+ disk_precheck
+
+ # sudo keep-alive only if some manager needs it.
+ local need_sudo=0 m
+ for m in "${PKG_MANAGERS[@]}"; do
+ pkg_detect "$m" || continue
+ pkg_is_dev "$m" && (( ! ARG_DEV )) && continue
+ [[ $m == fwupd ]] && (( ! ARG_FIRMWARE )) && continue
+ if pkg_needs_sudo "$m"; then need_sudo=1; break; fi
+ done
+ if (( need_sudo )) && (( ! DRY_RUN )) && ! sys_is_root; then
+ if ! sudo -v; then
+ ui_err "sudo authentication failed"
+ exit 1
fi
+ ( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null; sleep 60; done ) &
+ SUDO_KEEPALIVE_PID=$!
+ disown "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
+ fi
+
+ (( ARG_REFRESH_MIRRORS )) && mirrors_refresh
+ (( ARG_SNAPSHOT )) && snapshot_create
+
+ # Snapshot installed packages for diff.
+ diff_snapshot "$DIFF_BEFORE" 2>/dev/null || true
- if [[ $LINE == "utils/trash" && $ARGV =~ "-nt" ]]; then
+ hooks_run pre
+
+ local detected=() parallel=() serial=()
+ for m in "${PKG_MANAGERS[@]}"; do
+ pkg_detect "$m" || continue
+ if pkg_is_dev "$m" && (( ! ARG_DEV )); then continue; fi
+ if [[ $m == fwupd ]]; then
+ (( ARG_NO_FIRMWARE )) && continue
+ (( ARG_FIRMWARE )) || continue
+ fi
+ if (( ARG_RESUME )) && resume_has "$m"; then
+ ui_muted "skip $m (resume)"; continue
+ fi
+ if [[ -n $ARG_ONLY ]] && ! _arg_list_has "$ARG_ONLY" "$m"; then
continue
fi
+ if [[ -n $ARG_EXCEPT ]] && _arg_list_has "$ARG_EXCEPT" "$m"; then
+ ui_muted "skip $m (--except)"; continue
+ fi
+ detected+=("$m")
+ if (( ARG_PARALLEL )) && pkg_is_parallelizable "$m"; then
+ parallel+=("$m")
+ else
+ serial+=("$m")
+ fi
+ done
- if [[ $LINE != "" ]]; then
- source $SRC/$LINE.sh
- if [[ $LINE != "utils/privileges" ]]; then
- if [[ $LINE =~ "package/" ]]; then
- echo "$BLUE$PKG COMPLETED$NORMAL\n" >> $SRC/.hist
- fi
- fi
+ local total=${#detected[@]}
+ (( total == 0 )) && ui_warn "No supported package managers detected."
+
+ local idx=0
+ for m in "${serial[@]}"; do
+ idx=$((idx+1))
+ ui_step "$idx" "$total" "$m"
+ if pkg_run "$m"; then
+ SUMMARY_MANAGERS+=("$m"); SUMMARY_RESULTS+=("ok")
+ resume_mark "$m"
+ else
+ SUMMARY_MANAGERS+=("$m"); SUMMARY_RESULTS+=("fail")
+ SUMMARY_ERRORS=$((SUMMARY_ERRORS+1))
fi
done
- # Notify if errors are present
- if [[ $(grep -v "[0-9]*:[0-9]*:[0-9]*:[0-9]*" $ROOT.log | wc -l) -gt 0 ]]; then
- puts LOGO "\n\nSomething is not working correctly, type \"up -err\" for further informations\a"
+ if (( ${#parallel[@]} > 0 )); then
+ ui_step "$((idx+1))" "$total" "parallel: ${parallel[*]}"
+ local pids=() names=() logs=()
+ for m in "${parallel[@]}"; do
+ local lf="$EMERGER_STATE/parallel-$m.log"
+ ( pkg_run "$m" >"$lf" 2>&1 ) &
+ pids+=($!); names+=("$m"); logs+=("$lf")
+ done
+ local i
+ for i in "${!pids[@]}"; do
+ if wait "${pids[i]}"; then
+ SUMMARY_MANAGERS+=("${names[i]}"); SUMMARY_RESULTS+=("ok")
+ ui_done "${names[i]}"
+ resume_mark "${names[i]}"
+ else
+ SUMMARY_MANAGERS+=("${names[i]}"); SUMMARY_RESULTS+=("fail")
+ SUMMARY_ERRORS=$((SUMMARY_ERRORS+1))
+ ui_fail "${names[i]}"
+ tail -n 10 "${logs[i]}" | progress_highlight >&2 || true
+ fi
+ done
fi
- echo -ne "\n${BLUE}eMerger COMPLETED$NORMAL\n"
-fi
+ (( ARG_NO_CACHE )) || clean_cache
+ (( ARG_NO_TRASH )) || clean_trash
+
+ hooks_run post
+
+ # Post-snapshot diff.
+ diff_snapshot "$DIFF_AFTER" 2>/dev/null || true
+ diff_compute "$DIFF_BEFORE" "$DIFF_AFTER" "$DIFF_LAST" 2>/dev/null || true
+
+ summary_print $((SECONDS - start))
+ notify_send_result
-rm $SRC/.hist
+ # Restart ibus if it's running: updates to ibus/glib/gtk replace files on
+ # disk while the daemon keeps the old versions mapped in memory, which
+ # breaks input methods until the daemon is recycled. No-op if absent.
+ if command -v ibus >/dev/null 2>&1 && pgrep -x ibus-daemon >/dev/null 2>&1; then
+ ibus restart >/dev/null 2>&1 || true
+ fi
+
+ # Clean resume file on full success.
+ if (( SUMMARY_ERRORS == 0 )); then
+ resume_clear
+ fi
+
+ # Propagate failure upward.
+ if (( SUMMARY_ERRORS > 0 )); then
+ exit 3
+ fi
+ if (( ARG_REBOOT_EXIT )) && (( ${REBOOT_NEEDED:-0} == 1 )); then
+ exit 4
+ fi
+}
-exit 0
+main
diff --git a/src/lib/args.sh b/src/lib/args.sh
new file mode 100644
index 0000000..655ff06
--- /dev/null
+++ b/src/lib/args.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+# Argument parser. Shift-based so flags can take values.
+
+ARG_HELP=0; ARG_VERSION=0
+ARG_UP=0; ARG_AU=0; ARG_ERR=0; ARG_RC=0; ARG_HISTORY=0
+ARG_NO_LOGO=0; ARG_NO_INFO=0; ARG_NO_CACHE=0; ARG_NO_TRASH=0
+ARG_WEATHER=0
+ARG_QUIET=0; ARG_VERBOSE=0; ARG_DRY=0
+ARG_INTERACTIVE=0
+ARG_FIRMWARE=0; ARG_NO_FIRMWARE=0; ARG_DEV=0; ARG_SECURITY=0
+ARG_YES=0; ARG_PARALLEL=0
+ARG_NO_EMOJI=0
+ARG_SNAPSHOT=0
+ARG_REFRESH_MIRRORS=0
+ARG_RESUME=0
+ARG_CHANGED=0
+ARG_DOCTOR=0
+ARG_CHANGELOG=""
+ARG_REPORT=""
+ARG_PROFILE=""
+ARG_LIST_PROFILES=0
+ARG_REBOOT=0
+ARG_JSON=0
+ARG_REBOOT_EXIT=0
+ARG_ROLLBACK=0
+ARG_DOWNLOAD_ONLY=0
+ARG_METRICS=""
+ARG_ONLY=""
+ARG_EXCEPT=""
+QUIET_LEVEL=0
+
+_missing_value() { printf 'Missing value for %s\n' "$1" >&2; exit 2; }
+
+# Expand short-flag bundles: "-nv" -> "-n -v", "-ynv" -> "-y -n -v".
+# Only bundles whose letters all map to known single-letter short flags are
+# expanded; anything else is passed through unchanged so the main parser can
+# still error on unknown flags.
+_args_expand_bundles() {
+ local a letters i ch expanded
+ local -a out=()
+ for a in "$@"; do
+ # Known compound short flags that must stay atomic.
+ case "$a" in
+ -up|-au|-err|-rc|-nl|-ni|-nc|-nt|-qq|-qqq|-xyzzy|--*|-h|-V|-n|-v|-q|-y|-i|-w)
+ out+=("$a"); continue ;;
+ esac
+ # Only consider single-dash, 3+ chars, no '=', no numerics.
+ if [[ $a =~ ^-[A-Za-z]{2,}$ ]]; then
+ letters="${a#-}"; expanded=1
+ for (( i=0; i<${#letters}; i++ )); do
+ ch="${letters:i:1}"
+ case "$ch" in
+ h|V|n|v|q|y|i|w) : ;;
+ *) expanded=0; break ;;
+ esac
+ done
+ if (( expanded )); then
+ for (( i=0; i<${#letters}; i++ )); do
+ out+=("-${letters:i:1}")
+ done
+ continue
+ fi
+ fi
+ out+=("$a")
+ done
+ printf '%s\n' "${out[@]}"
+}
+
+args_parse() {
+ local -a argv=()
+ if (( $# > 0 )); then
+ mapfile -t argv < <(_args_expand_bundles "$@")
+ fi
+ set -- "${argv[@]}"
+ while (( $# > 0 )); do
+ case "$1" in
+ -h|--help|-help) ARG_HELP=1 ;;
+ -V|--version) ARG_VERSION=1 ;;
+ -up|--self-update) ARG_UP=1 ;;
+ -au|--auto-update) ARG_AU=1 ;;
+ -err|--errors) ARG_ERR=1 ;;
+ -rc|--rebuild-cache) ARG_RC=1 ;;
+ --history) ARG_HISTORY=1 ;;
+
+ -nl|--no-logo) ARG_NO_LOGO=1 ;;
+ -ni|--no-info) ARG_NO_INFO=1 ;;
+ -nc|--no-cache) ARG_NO_CACHE=1 ;;
+ -nt|--no-trash) ARG_NO_TRASH=1 ;;
+ -w|--weather) ARG_WEATHER=1 ;;
+
+ -q|--quiet) ARG_QUIET=1; QUIET_LEVEL=$(( QUIET_LEVEL + 1 )) ;;
+ -qq) ARG_QUIET=1; QUIET_LEVEL=2 ;;
+ -qqq) ARG_QUIET=1; QUIET_LEVEL=3 ;;
+ -v|--verbose) ARG_VERBOSE=1; UI_VERBOSE=1 ;;
+ -n|--dry-run) ARG_DRY=1; DRY_RUN=1 ;;
+ -i|--interactive) ARG_INTERACTIVE=1 ;;
+
+ --firmware) ARG_FIRMWARE=1 ;;
+ --no-firmware) ARG_NO_FIRMWARE=1 ;;
+ --dev) ARG_DEV=1 ;;
+ --security) ARG_SECURITY=1 ;;
+ -y|--yes) ARG_YES=1 ;;
+ --parallel) ARG_PARALLEL=1 ;;
+ --no-emoji) ARG_NO_EMOJI=1; ui_reinit ;;
+
+ --snapshot) ARG_SNAPSHOT=1 ;;
+ --refresh-mirrors) ARG_REFRESH_MIRRORS=1 ;;
+ --resume) ARG_RESUME=1 ;;
+ --changed) ARG_CHANGED=1 ;;
+ --reboot) ARG_REBOOT=1 ;;
+ --reboot-exit) ARG_REBOOT_EXIT=1 ;;
+ --rollback) ARG_ROLLBACK=1 ;;
+ --download-only|--offline) ARG_DOWNLOAD_ONLY=1 ;;
+ --json) ARG_JSON=1 ;;
+
+ --doctor) ARG_DOCTOR=1 ;;
+ --list-profiles) ARG_LIST_PROFILES=1 ;;
+
+ --profile) shift; [[ $# -gt 0 ]] || _missing_value --profile; ARG_PROFILE="$1" ;;
+ --profile=*) ARG_PROFILE="${1#*=}" ;;
+ --changelog) shift; [[ $# -gt 0 ]] || _missing_value --changelog; ARG_CHANGELOG="$1" ;;
+ --changelog=*) ARG_CHANGELOG="${1#*=}" ;;
+ --report) shift; [[ $# -gt 0 ]] || _missing_value --report; ARG_REPORT="$1" ;;
+ --report=*) ARG_REPORT="${1#*=}" ;;
+ --metrics) shift; [[ $# -gt 0 ]] || _missing_value --metrics; ARG_METRICS="$1" ;;
+ --metrics=*) ARG_METRICS="${1#*=}" ;;
+ --only) shift; [[ $# -gt 0 ]] || _missing_value --only; ARG_ONLY="$1" ;;
+ --only=*) ARG_ONLY="${1#*=}" ;;
+ --except) shift; [[ $# -gt 0 ]] || _missing_value --except; ARG_EXCEPT="$1" ;;
+ --except=*) ARG_EXCEPT="${1#*=}" ;;
+
+ -xyzzy)
+ printf "Let's keep its memory alive.\n"; exit 0 ;;
+ --) shift; break ;;
+ *)
+ printf 'Unknown argument: %s (try "up --help")\n' "$1" >&2
+ exit 2
+ ;;
+ esac
+ shift
+ done
+}
+
+# Pre-scan for --profile so the profile file can set defaults BEFORE the
+# explicit CLI flags parsed by args_parse take effect (CLI wins).
+args_prescan_profile() {
+ local i argv=("$@")
+ for (( i=0; i<${#argv[@]}; i++ )); do
+ case "${argv[i]}" in
+ --profile=*) PROFILE_PRELOAD="${argv[i]#*=}"; return ;;
+ --profile) PROFILE_PRELOAD="${argv[i+1]:-}"; return ;;
+ esac
+ done
+ PROFILE_PRELOAD=""
+}
diff --git a/src/lib/changelog.sh b/src/lib/changelog.sh
new file mode 100644
index 0000000..0a3112e
--- /dev/null
+++ b/src/lib/changelog.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Fetch recent changelog for a package using the installed manager.
+
+changelog_show() {
+ local pkg="$1"
+ if [[ -z $pkg ]]; then
+ ui_err "usage: up --changelog "
+ return 2
+ fi
+ ui_title "Changelog: $pkg"
+ if sys_has apt && apt changelog "$pkg" 2>/dev/null | head -n 60 | grep -q .; then
+ apt changelog "$pkg" 2>/dev/null | head -n 60
+ return 0
+ fi
+ if sys_has dnf; then
+ if dnf changelog "$pkg" 2>/dev/null | grep -q .; then
+ dnf changelog "$pkg" 2>/dev/null | head -n 60
+ return 0
+ fi
+ if dnf updateinfo info "$pkg" 2>/dev/null | grep -q .; then
+ dnf updateinfo info "$pkg" 2>/dev/null | head -n 60
+ return 0
+ fi
+ fi
+ if sys_has pacman; then
+ pacman -Qi "$pkg" 2>/dev/null || pacman -Si "$pkg" 2>/dev/null
+ ui_muted "(pacman has no native changelog; see https://archlinux.org/packages/)"
+ return 0
+ fi
+ if sys_has brew; then
+ brew log --oneline -n 20 "$pkg" 2>/dev/null || brew info "$pkg"
+ return 0
+ fi
+ ui_err "No supported package manager with changelog info found."
+ return 1
+}
diff --git a/src/lib/clean.sh b/src/lib/clean.sh
new file mode 100644
index 0000000..954a3e9
--- /dev/null
+++ b/src/lib/clean.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Cache and trash cleaners. Safe-by-default: prompt unless -y.
+
+_clean_size_kb() {
+ [[ -d "$1" ]] || { echo 0; return; }
+ du -sk "$1" 2>/dev/null | awk '{print $1}'
+}
+
+_clean_confirm() {
+ local prompt="$1"
+ if (( ARG_YES )); then echo y; return; fi
+ local ans
+ read -r -p " $prompt [y/N]: " ans || ans=n
+ printf '%s' "$ans"
+}
+
+clean_cache() {
+ local target="$HOME/.cache"
+ ui_title "User cache"
+ if [[ ! -d $target ]]; then
+ ui_ok "empty"; return 0
+ fi
+ local size; size=$(du -sh "$target" 2>/dev/null | cut -f1)
+ ui_muted "$target ($size)"
+ local ans; ans=$(_clean_confirm "Clean user cache?")
+ if [[ $ans == [yY]* ]]; then
+ local before; before=$(_clean_size_kb "$target")
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] would remove contents of $target"
+ else
+ find "$target" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null || true
+ fi
+ local after; after=$(_clean_size_kb "$target")
+ SUMMARY_FREED=$(( SUMMARY_FREED + before - after ))
+ ui_ok "cache cleaned"
+ else
+ ui_muted "skipped"
+ fi
+}
+
+clean_trash() {
+ local files="$HOME/.local/share/Trash/files"
+ local info="$HOME/.local/share/Trash/info"
+ ui_title "Trash"
+ if [[ ! -d $files ]]; then
+ ui_ok "empty"; return 0
+ fi
+ local count
+ count=$(find "$files" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l)
+ if (( count == 0 )); then
+ ui_ok "empty"; return 0
+ fi
+ local size; size=$(du -sh "$files" 2>/dev/null | cut -f1)
+ ui_muted "$files ($size, $count items)"
+ local ans; ans=$(_clean_confirm "Empty trash?")
+ if [[ $ans == [yY]* ]]; then
+ local before; before=$(_clean_size_kb "$files")
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] would empty trash"
+ else
+ find "$files" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null || true
+ [[ -d $info ]] && find "$info" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null || true
+ fi
+ local after; after=$(_clean_size_kb "$files")
+ SUMMARY_FREED=$(( SUMMARY_FREED + before - after ))
+ ui_ok "trash emptied"
+ else
+ ui_muted "skipped"
+ fi
+}
diff --git a/src/lib/diff.sh b/src/lib/diff.sh
new file mode 100644
index 0000000..fa89ce4
--- /dev/null
+++ b/src/lib/diff.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Snapshot installed packages before/after; compute diff on demand.
+
+: "${DIFF_BEFORE:=$EMERGER_STATE/pkgs.before}"
+: "${DIFF_AFTER:=$EMERGER_STATE/pkgs.after}"
+: "${DIFF_LAST:=$EMERGER_STATE/pkgs.diff}"
+
+diff_snapshot() {
+ local out="$1"
+ : >"$out"
+ if sys_has dpkg-query; then
+ dpkg-query -W -f='apt\t${Package}\t${Version}\n' 2>/dev/null >>"$out"
+ fi
+ if sys_has pacman; then
+ pacman -Q 2>/dev/null | awk '{print "pacman\t"$1"\t"$2}' >>"$out"
+ fi
+ if sys_has rpm; then
+ rpm -qa --qf 'rpm\t%{NAME}\t%{VERSION}-%{RELEASE}\n' 2>/dev/null >>"$out"
+ fi
+ if sys_has flatpak; then
+ flatpak list --columns=application,version 2>/dev/null | awk -F'\t' 'NR>1 || tolower($1) !~ /^application$/ {if ($1!="") print "flatpak\t"$1"\t"$2}' >>"$out"
+ fi
+ if sys_has snap; then
+ snap list 2>/dev/null | tail -n +2 | awk '{print "snap\t"$1"\t"$2}' >>"$out"
+ fi
+ if sys_has brew; then
+ brew list --versions 2>/dev/null | awk '{print "brew\t"$1"\t"$2}' >>"$out"
+ fi
+ sort -o "$out" "$out" 2>/dev/null || true
+}
+
+diff_compute() {
+ local before="$1" after="$2" out="$3"
+ : >"$out"
+ [[ -f $before && -f $after ]] || return 0
+ # Build maps by "mgr\tname" -> version
+ awk -F'\t' '{print $1"\t"$2"\t"$3}' "$before" | sort -u >"${out}.b"
+ awk -F'\t' '{print $1"\t"$2"\t"$3}' "$after" | sort -u >"${out}.a"
+ # added
+ comm -13 <(awk -F'\t' '{print $1"\t"$2}' "${out}.b") <(awk -F'\t' '{print $1"\t"$2}' "${out}.a") | while IFS=$'\t' read -r mgr name; do
+ local v; v=$(awk -F'\t' -v m="$mgr" -v n="$name" '$1==m&&$2==n{print $3; exit}' "${out}.a")
+ printf '+\t%s\t%s\t%s\n' "$mgr" "$name" "$v" >>"$out"
+ done
+ # removed
+ comm -23 <(awk -F'\t' '{print $1"\t"$2}' "${out}.b") <(awk -F'\t' '{print $1"\t"$2}' "${out}.a") | while IFS=$'\t' read -r mgr name; do
+ local v; v=$(awk -F'\t' -v m="$mgr" -v n="$name" '$1==m&&$2==n{print $3; exit}' "${out}.b")
+ printf -- '-\t%s\t%s\t%s\n' "$mgr" "$name" "$v" >>"$out"
+ done
+ # upgraded: same mgr+name, different version
+ join -t$'\t' -j1 <(awk -F'\t' '{print $1"_"$2"\t"$3}' "${out}.b" | sort) \
+ <(awk -F'\t' '{print $1"_"$2"\t"$3}' "${out}.a" | sort) \
+ | awk -F'\t' '$2!=$3{split($1,x,"_"); print "~\t"x[1]"\t"x[2]"\t"$2" -> "$3}' >>"$out"
+ rm -f "${out}.a" "${out}.b"
+}
+
+diff_show() {
+ local f="${1:-$DIFF_LAST}"
+ if [[ ! -s $f ]]; then
+ ui_muted "No package changes recorded."
+ return
+ fi
+ ui_title "Package changes"
+ local added removed upgraded
+ added=$(grep -c '^+' "$f" || true)
+ removed=$(grep -c '^-' "$f" || true)
+ upgraded=$(grep -c '^~' "$f" || true)
+ printf ' %s+%s %d added %s-%s %d removed %s~%s %d upgraded\n\n' \
+ "$C_GREEN" "$C_RESET" "${added:-0}" \
+ "$C_RED" "$C_RESET" "${removed:-0}" \
+ "$C_YELLOW" "$C_RESET" "${upgraded:-0}"
+ awk -F'\t' '
+ $1=="~"{printf " \033[33m~\033[0m %-10s %-30s %s\n", $2, $3, $4}
+ $1=="+"{printf " \033[32m+\033[0m %-10s %-30s %s\n", $2, $3, $4}
+ $1=="-"{printf " \033[31m-\033[0m %-10s %-30s %s\n", $2, $3, $4}
+ ' "$f"
+}
+
+diff_count_changed() {
+ local f="${1:-$DIFF_LAST}"
+ [[ -s $f ]] || { echo 0; return; }
+ wc -l <"$f"
+}
diff --git a/src/lib/disk.sh b/src/lib/disk.sh
new file mode 100644
index 0000000..d9fc705
--- /dev/null
+++ b/src/lib/disk.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Disk-space precheck: warn/abort if the update target is tight.
+
+: "${DISK_MIN_FREE_MB:=1024}"
+
+disk_free_mb() {
+ local path="${1:-/}"
+ df -Pm "$path" 2>/dev/null | awk 'NR==2{print $4}'
+}
+
+disk_precheck() {
+ local free; free=$(disk_free_mb /)
+ [[ -z $free ]] && return 0
+ if (( free < DISK_MIN_FREE_MB )); then
+ ui_warn "Low disk space on / (${free}MB free, threshold ${DISK_MIN_FREE_MB}MB)."
+ if (( ARG_YES == 0 )); then
+ local ans
+ read -r -p " Continue anyway? [y/N]: " ans || ans=n
+ [[ $ans == [yY]* ]] || { ui_muted "Aborted by user."; exit 1; }
+ fi
+ else
+ log_info "disk precheck ok: ${free}MB free on /"
+ fi
+}
diff --git a/src/lib/doctor.sh b/src/lib/doctor.sh
new file mode 100644
index 0000000..5f384d4
--- /dev/null
+++ b/src/lib/doctor.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Health check: run `up --doctor` and see what's wrong with the environment.
+
+_doctor_issue=0
+
+_dok() { ui_ok "$*"; }
+_dwarn() { ui_warn "$*"; _doctor_issue=$((_doctor_issue+1)); }
+_derr() { ui_err "$*"; _doctor_issue=$((_doctor_issue+1)); }
+
+doctor_run() {
+ _doctor_issue=0
+ ui_title "eMerger doctor"
+
+ # Shell/env
+ if [[ -n $BASH_VERSION ]]; then
+ _dok "bash $BASH_VERSION"
+ else
+ _dwarn "running outside bash"
+ fi
+
+ # sudo
+ if sys_is_root; then
+ _dok "running as root"
+ elif sudo -n true 2>/dev/null; then
+ _dok "sudo cached"
+ else
+ ui_info "sudo: will prompt when needed"
+ fi
+
+ # Disk
+ local free_mb; free_mb=$(df -Pm / | awk 'NR==2{print $4}')
+ if [[ -n $free_mb ]] && (( free_mb < 1024 )); then
+ _dwarn "low disk space on / (${free_mb}MB free)"
+ else
+ _dok "disk: ${free_mb:-?}MB free on /"
+ fi
+
+ # Network
+ if sys_has curl && curl -fsS --max-time 4 -o /dev/null https://github.com 2>/dev/null; then
+ _dok "network: reachable"
+ elif sys_has curl; then
+ _dwarn "network: github.com unreachable"
+ fi
+
+ # State dir writable
+ if [[ -w $EMERGER_STATE ]] || mkdir -p "$EMERGER_STATE" 2>/dev/null; then
+ _dok "state: $EMERGER_STATE writable"
+ else
+ _derr "state not writable: $EMERGER_STATE"
+ fi
+
+ # Package manager health
+ local mgr
+ for mgr in "${PKG_MANAGERS[@]}"; do
+ pkg_detect "$mgr" || continue
+ _doctor_pkg "$mgr"
+ done
+
+ # Reboot flag
+ reboot_check
+ if (( REBOOT_NEEDED )); then
+ ui_warn "reboot pending ($REBOOT_REASON)"
+ fi
+
+ ui_hr
+ if (( _doctor_issue == 0 )); then
+ _dok "all clear"
+ return 0
+ fi
+ ui_warn "$_doctor_issue issue(s) found"
+ return 1
+}
+
+_doctor_pkg() {
+ local m="$1"
+ case "$m" in
+ apt)
+ if ! dpkg --audit 2>/dev/null | grep -q .; then
+ _dok "apt: dpkg --audit clean"
+ else
+ _dwarn "apt: dpkg --audit reports issues"
+ fi
+ ;;
+ pacman)
+ if pacman -Dk >/dev/null 2>&1; then
+ _dok "pacman: database ok"
+ else
+ _dwarn "pacman: database has issues (pacman -Dk)"
+ fi
+ ;;
+ dnf|yum)
+ if "$m" check --quiet >/dev/null 2>&1; then
+ _dok "$m: check ok"
+ else
+ ui_info "$m: check not available or ran clean"
+ fi
+ ;;
+ brew)
+ if brew doctor >/dev/null 2>&1; then
+ _dok "brew: doctor ok"
+ else
+ _dwarn "brew: doctor reports issues"
+ fi
+ ;;
+ *)
+ _dok "$m: detected"
+ ;;
+ esac
+}
diff --git a/src/lib/estimate.sh b/src/lib/estimate.sh
new file mode 100644
index 0000000..360a254
--- /dev/null
+++ b/src/lib/estimate.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Estimate step duration based on recent history.jsonl entries.
+
+estimate_for() {
+ local mgr="$1"
+ local hist="$EMERGER_STATE/history.jsonl"
+ [[ -f $hist ]] || { printf ''; return; }
+ # Crude: average run duration over last 5 runs that included this manager.
+ tail -n 20 "$hist" 2>/dev/null | awk -v m="\"$mgr\"" '
+ index($0, m) > 0 {
+ match($0, /"duration":[0-9]+/)
+ if (RLENGTH > 0) {
+ d = substr($0, RSTART+11, RLENGTH-11)
+ sum += d; n += 1
+ }
+ }
+ END {
+ if (n > 0) printf "~%ds", int(sum/n)
+ }
+ '
+}
diff --git a/src/lib/help.txt b/src/lib/help.txt
new file mode 100644
index 0000000..a134cc0
--- /dev/null
+++ b/src/lib/help.txt
@@ -0,0 +1,95 @@
+eMerger - one-command system updater
+
+USAGE
+ up [options]
+
+GENERAL
+ -h, --help Show this help
+ -V, --version Show version
+ -i, --interactive Interactive menu (gum / whiptail / plain)
+ --doctor Run environment health check
+
+EXECUTION
+ -n, --dry-run Show what would be done, do nothing
+ -v, --verbose Stream package manager output live
+ -q, --quiet Quieter output (repeat for more: -q, -qq, -qqq)
+ -y, --yes Assume "yes" on prompts
+ --json Machine-readable run summary on stdout
+ Short single-letter flags bundle: -nv == -n -v, -ynv == -y -n -v
+
+SELECTION
+ --security Only security updates when supported
+ --firmware Include firmware via fwupdmgr
+ --no-firmware Force-skip firmware
+ --dev Include dev toolchains (rustup, cargo, npm, pip, gem)
+ --parallel Run independent user-space managers in parallel
+ --profile NAME Load a profile from profiles.d/.sh
+ --list-profiles List available profiles
+ --only LIST Comma-separated managers to keep (e.g. apt,flatpak)
+ --except LIST Comma-separated managers to skip
+
+SAFETY / INTEGRATION
+ --snapshot Take a pre-update snapshot (snapper/timeshift/btrfs)
+ --rollback Revert to the latest eMerger snapshot and exit
+ --refresh-mirrors Rank/refresh mirrors (reflector, netselect-apt)
+ --resume Skip managers that completed in the last interrupted run
+ --reboot Reboot now if the system requires it
+ --reboot-exit Exit 4 instead of 0 when a reboot is required
+ --download-only Download packages but do not install (apt/dnf/pacman/zypper)
+ --offline Alias for --download-only
+
+DISPLAY
+ -nl, --no-logo Hide logo
+ -ni, --no-info Hide system info line
+ -nc, --no-cache Skip user cache cleaning
+ -nt, --no-trash Skip trash cleaning
+ -w, --weather Show weather line via wttr.in
+ --no-emoji Force ASCII glyphs only
+
+INSPECTION
+ --changed Show packages added/removed/upgraded in the last run
+ --changelog PKG Show upstream changelog for PKG
+ --history Show the last runs
+ --report [FILE] Export the last report to Markdown
+ --metrics FILE Export last run as Prometheus textfile
+ -err, --errors Show logged errors
+
+MAINTENANCE
+ -up, --self-update Update eMerger itself via git (fast-forward)
+ -au, --auto-update Install weekly auto-update (systemd timer or cron)
+ -rc, --rebuild-cache Clear detection cache
+
+EXAMPLES
+ up # normal update
+ up -nv # preview + live stream (short bundle)
+ up --security -y # unattended security updates
+ up --dev --firmware # full refresh + toolchains + firmware
+ up --snapshot --refresh-mirrors -y
+ up --only apt,flatpak # limit to specific managers
+ up --except snap,firmware # run everything else
+ up --download-only -y # prefetch while online, install later
+ up --rollback # revert the last pre-update snapshot
+ up --json | jq . # consume in scripts / CI
+ up --metrics /var/lib/node_exporter/textfile_collector/emerger.prom
+ up --reboot-exit # exit 4 if a reboot is pending
+
+CONFIG
+ ~/.config/emerger/config.sh Sourced before argument parsing
+ ~/.config/emerger/profiles.d/*.sh User profiles
+ ~/.config/emerger/hooks/pre.d/*.sh Pre-update hooks
+ ~/.config/emerger/hooks/post.d/*.sh Post-update hooks
+ ~/.config/emerger/managers.d/*.sh Third-party package manager plugins
+ ~/.config/emerger/ignore.list Packages to never upgrade (pacman native)
+ EMERGER_CACHE_TTL Detection cache TTL in seconds (default 86400, 0 disables)
+
+STATE
+ ~/.local/state/emerger/emerger.log Log
+ ~/.local/state/emerger/history.jsonl Run history (one JSON per line)
+ ~/.local/state/emerger/pkgs.diff Last run package diff
+
+EXIT CODES
+ 0 success
+ 1 runtime failure (e.g. sudo, disk, lock)
+ 2 argument parsing error
+ 3 one or more package managers failed (script itself ran to completion)
+ 4 reboot required (only with --reboot-exit)
diff --git a/src/lib/hooks.sh b/src/lib/hooks.sh
new file mode 100644
index 0000000..f31aace
--- /dev/null
+++ b/src/lib/hooks.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Run user hook scripts from $EMERGER_CONFIG/hooks/{pre,post}.d/*.sh
+
+hooks_run() {
+ local phase="$1"
+ local dir="$EMERGER_CONFIG/hooks/${phase}.d"
+ [[ -d $dir ]] || return 0
+ local h ran=0
+ for h in "$dir"/*.sh; do
+ [[ -f $h ]] || continue
+ (( ran == 0 )) && ui_title "Hooks ($phase)"
+ ran=1
+ ui_sub "$(basename "$h")"
+ if (( DRY_RUN )); then continue; fi
+ if ! bash "$h"; then
+ ui_warn "hook $(basename "$h") failed"
+ log_warn "hook $phase/$h failed"
+ fi
+ done
+}
diff --git a/src/lib/ignore.sh b/src/lib/ignore.sh
new file mode 100644
index 0000000..bad1f7b
--- /dev/null
+++ b/src/lib/ignore.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Ignore list: packages that should never be upgraded.
+# File: $EMERGER_CONFIG/ignore.list (one pattern per line, # comments allowed)
+# Native support: pacman (--ignore). Others: advisory only.
+
+: "${IGNORE_FILE:=$EMERGER_CONFIG/ignore.list}"
+IGNORE_LIST=()
+
+ignore_load() {
+ IGNORE_LIST=()
+ [[ -f $IGNORE_FILE ]] || return 0
+ local line
+ while IFS= read -r line; do
+ line="${line%%#*}"
+ line="${line//[[:space:]]/}"
+ [[ -z $line ]] && continue
+ IGNORE_LIST+=("$line")
+ done <"$IGNORE_FILE"
+}
+
+ignore_pacman_flag() {
+ (( ${#IGNORE_LIST[@]} == 0 )) && return
+ local joined; joined=$(IFS=,; printf '%s' "${IGNORE_LIST[*]}")
+ printf -- '--ignore=%s' "$joined"
+}
+
+ignore_advisory() {
+ (( ${#IGNORE_LIST[@]} == 0 )) && return 0
+ ui_muted "Ignore list: ${IGNORE_LIST[*]}"
+ if ! sys_has pacman; then
+ ui_muted "(only pacman honors --ignore natively; hold the rest via apt-mark/dnf versionlock)"
+ fi
+}
diff --git a/src/lib/lock.sh b/src/lib/lock.sh
new file mode 100644
index 0000000..9d531eb
--- /dev/null
+++ b/src/lib/lock.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Global lock: prevent two concurrent eMerger runs from stepping on each other.
+
+: "${EMERGER_LOCK:=${XDG_RUNTIME_DIR:-${EMERGER_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/emerger}}/emerger.lock}"
+
+lock_acquire() {
+ exec 9>"$EMERGER_LOCK" 2>/dev/null || return 0
+ if ! command -v flock >/dev/null 2>&1; then
+ return 0
+ fi
+ if ! flock -n 9; then
+ ui_err "Another eMerger run is in progress (lock: $EMERGER_LOCK)."
+ ui_muted "Wait for it, or remove the lock file if you're sure it's stale."
+ return 1
+ fi
+ printf '%d\n' "$$" >&9
+ return 0
+}
+
+lock_release() {
+ exec 9>&- 2>/dev/null || true
+}
diff --git a/src/lib/log.sh b/src/lib/log.sh
new file mode 100644
index 0000000..0fc4c19
--- /dev/null
+++ b/src/lib/log.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Structured logging to $EMERGER_LOG.
+
+: "${EMERGER_STATE:=${XDG_STATE_HOME:-$HOME/.local/state}/emerger}"
+: "${EMERGER_LOG:=$EMERGER_STATE/emerger.log}"
+
+log_init() {
+ mkdir -p "$(dirname "$EMERGER_LOG")"
+ touch "$EMERGER_LOG"
+ if [[ $(wc -l <"$EMERGER_LOG" 2>/dev/null || echo 0) -gt 2000 ]]; then
+ tail -n 2000 "$EMERGER_LOG" >"${EMERGER_LOG}.tmp" && mv "${EMERGER_LOG}.tmp" "$EMERGER_LOG"
+ fi
+}
+
+_log() {
+ local level="$1"; shift
+ printf '%s|%s|%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >>"$EMERGER_LOG" 2>/dev/null || true
+}
+log_info() { _log INFO "$@"; }
+log_warn() { _log WARN "$@"; }
+log_error() { _log ERROR "$@"; }
diff --git a/src/lib/metrics.sh b/src/lib/metrics.sh
new file mode 100644
index 0000000..6f93c92
--- /dev/null
+++ b/src/lib/metrics.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Prometheus textfile-collector export.
+# Reads the most recent entry of history.jsonl and renders a .prom file.
+
+metrics_export() {
+ local out="$1"
+ local hist="$EMERGER_STATE/history.jsonl"
+ if [[ ! -f $hist ]]; then
+ ui_err "No history yet ($hist missing)"
+ return 1
+ fi
+ local last
+ last=$(tail -n 1 "$hist")
+ [[ -z $last ]] && { ui_err "history.jsonl is empty"; return 1; }
+
+ local ts dur freed errs reboot
+ ts=$(printf '%s' "$last" | sed -n 's/.*"ts":"\([^"]*\)".*/\1/p')
+ dur=$(printf '%s' "$last" | sed -n 's/.*"duration":\([0-9]*\).*/\1/p')
+ freed=$(printf '%s' "$last" | sed -n 's/.*"freed_kb":\([0-9]*\).*/\1/p')
+ errs=$(printf '%s' "$last" | sed -n 's/.*"errors":\([0-9]*\).*/\1/p')
+ reboot=$(printf '%s' "$last" | sed -n 's/.*"reboot":\([0-9]*\).*/\1/p')
+
+ # epoch (best-effort, GNU date syntax).
+ local epoch=0
+ if [[ -n $ts ]]; then
+ epoch=$(date -d "$ts" +%s 2>/dev/null || echo 0)
+ fi
+
+ local tmp="$out.tmp.$$"
+ {
+ printf '# HELP emerger_last_run_timestamp_seconds Unix timestamp of the last eMerger run\n'
+ printf '# TYPE emerger_last_run_timestamp_seconds gauge\n'
+ printf 'emerger_last_run_timestamp_seconds %s\n' "${epoch:-0}"
+ printf '# HELP emerger_last_run_duration_seconds Duration of the last run\n'
+ printf '# TYPE emerger_last_run_duration_seconds gauge\n'
+ printf 'emerger_last_run_duration_seconds %s\n' "${dur:-0}"
+ printf '# HELP emerger_last_run_freed_bytes Bytes freed by the last run\n'
+ printf '# TYPE emerger_last_run_freed_bytes gauge\n'
+ printf 'emerger_last_run_freed_bytes %s\n' "$(( ${freed:-0} * 1024 ))"
+ printf '# HELP emerger_last_run_errors Manager failures in the last run\n'
+ printf '# TYPE emerger_last_run_errors gauge\n'
+ printf 'emerger_last_run_errors %s\n' "${errs:-0}"
+ printf '# HELP emerger_reboot_required Whether a reboot is pending (0/1)\n'
+ printf '# TYPE emerger_reboot_required gauge\n'
+ printf 'emerger_reboot_required %s\n' "${reboot:-0}"
+
+ # Per-manager success flag.
+ printf '# HELP emerger_manager_ok Per-manager success in the last run (1=ok,0=fail)\n'
+ printf '# TYPE emerger_manager_ok gauge\n'
+ printf '%s\n' "$last" | grep -oE '"name":"[^"]*","result":"[^"]*"' | \
+ while IFS= read -r pair; do
+ local name result val
+ name=$(printf '%s' "$pair" | sed -n 's/.*"name":"\([^"]*\)".*/\1/p')
+ result=$(printf '%s' "$pair" | sed -n 's/.*"result":"\([^"]*\)".*/\1/p')
+ val=0
+ [[ $result == ok ]] && val=1
+ printf 'emerger_manager_ok{manager="%s"} %s\n' "$name" "$val"
+ done
+ } >"$tmp"
+ mv "$tmp" "$out"
+ ui_ok "metrics written to $out"
+}
diff --git a/src/lib/mirrors.sh b/src/lib/mirrors.sh
new file mode 100644
index 0000000..f2c418d
--- /dev/null
+++ b/src/lib/mirrors.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Rank / refresh package mirrors where supported.
+
+mirrors_refresh() {
+ ui_title "Refresh mirrors"
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] would refresh mirrors"
+ return 0
+ fi
+ local did=0
+ if sys_has reflector; then
+ if sudo reflector --latest 20 --sort rate --protocol https --save /etc/pacman.d/mirrorlist >/dev/null 2>&1; then
+ ui_ok "reflector: pacman mirrorlist updated"
+ did=1
+ else
+ ui_warn "reflector failed"
+ fi
+ fi
+ if sys_has netselect-apt; then
+ local tmp; tmp=$(mktemp -t emerger.XXXXXX)
+ if sudo netselect-apt -o "$tmp" >/dev/null 2>&1 && [[ -s $tmp ]]; then
+ sudo install -m 0644 "$tmp" /etc/apt/sources.list.d/netselect.list
+ ui_ok "netselect-apt: wrote /etc/apt/sources.list.d/netselect.list"
+ did=1
+ fi
+ rm -f "$tmp"
+ fi
+ if sys_has dnf && dnf config-manager --help >/dev/null 2>&1; then
+ ui_muted "dnf: fastestmirror plugin handles this automatically when enabled"
+ fi
+ (( did )) || ui_muted "No mirror tool available; install 'reflector' (arch) or 'netselect-apt' (debian)."
+}
diff --git a/src/lib/notify.sh b/src/lib/notify.sh
new file mode 100644
index 0000000..2aaf1f2
--- /dev/null
+++ b/src/lib/notify.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Optional desktop notification at end of run.
+
+notify_send_result() {
+ sys_has notify-send || return 0
+ [[ -n ${DISPLAY:-} || -n ${WAYLAND_DISPLAY:-} ]] || return 0
+ local msg urgency=normal icon=emblem-default
+ if (( SUMMARY_ERRORS > 0 )); then
+ urgency=critical; icon=dialog-error
+ msg="Finished with $SUMMARY_ERRORS error(s)."
+ else
+ msg="${#SUMMARY_MANAGERS[@]} manager(s) updated."
+ fi
+ notify-send -a eMerger -u "$urgency" -i "$icon" "eMerger" "$msg" 2>/dev/null || true
+}
diff --git a/src/lib/packages.sh b/src/lib/packages.sh
new file mode 100644
index 0000000..641136f
--- /dev/null
+++ b/src/lib/packages.sh
@@ -0,0 +1,337 @@
+#!/usr/bin/env bash
+# Package manager registry and dispatcher.
+# Detection is cached in $EMERGER_CACHE/detected for speed.
+
+PKG_MANAGERS=(
+ pacman yay paru
+ apt dnf yum zypper xbps apk eopkg emerge nix
+ softwareupdate brew mas
+ fwupd
+ flatpak snap
+ rustup cargo npm pnpm pip gem
+)
+
+pkg_needs_sudo() {
+ case "$1" in
+ pacman|apt|dnf|yum|zypper|xbps|apk|eopkg|emerge|fwupd|snap|softwareupdate) return 0 ;;
+ nix) sys_has nixos-rebuild ;;
+ *)
+ if declare -F plugin_is >/dev/null && plugin_is "$1"; then
+ plugin_needs_sudo "$1"
+ else
+ return 1
+ fi
+ ;;
+ esac
+}
+
+pkg_is_dev() {
+ case "$1" in
+ rustup|cargo|npm|pnpm|pip|gem) return 0 ;;
+ *)
+ if declare -F plugin_is >/dev/null && plugin_is "$1"; then
+ plugin_is_dev "$1"
+ else
+ return 1
+ fi
+ ;;
+ esac
+}
+
+pkg_is_parallelizable() {
+ case "$1" in
+ flatpak|snap|brew|mas|rustup|cargo|npm|pnpm|pip|gem) return 0 ;;
+ *)
+ if declare -F plugin_is >/dev/null && plugin_is "$1"; then
+ plugin_is_parallel "$1"
+ else
+ return 1
+ fi
+ ;;
+ esac
+}
+
+# Cache TTL in seconds for the per-manager detection cache.
+# Override with EMERGER_CACHE_TTL (0 disables caching).
+: "${EMERGER_CACHE_TTL:=86400}"
+
+_pkg_detect_raw() {
+ case "$1" in
+ pacman) sys_has pacman ;;
+ apt) sys_has apt || sys_has apt-get ;;
+ dnf) sys_has dnf ;;
+ yum) sys_has yum && ! sys_has dnf ;;
+ zypper) sys_has zypper ;;
+ xbps) sys_has xbps-install ;;
+ apk) sys_has apk ;;
+ eopkg) sys_has eopkg ;;
+ emerge) sys_has emerge ;;
+ nix) sys_has nixos-rebuild || sys_has nix-env ;;
+ brew) sys_has brew ;;
+ mas) sys_has mas ;;
+ softwareupdate)
+ [[ $(uname -s) == Darwin ]] && sys_has softwareupdate ;;
+ flatpak) sys_has flatpak ;;
+ snap) sys_has snap ;;
+ yay) sys_has yay ;;
+ paru) sys_has paru ;;
+ fwupd) sys_has fwupdmgr ;;
+ rustup) sys_has rustup ;;
+ cargo) sys_has cargo && cargo install-update --version >/dev/null 2>&1 ;;
+ npm) sys_has npm ;;
+ pnpm) sys_has pnpm ;;
+ pip) sys_has pip || sys_has pip3 ;;
+ gem) sys_has gem ;;
+ *) return 1 ;;
+ esac
+}
+
+_pkg_cache_fresh() {
+ local cache="$1"
+ (( EMERGER_CACHE_TTL <= 0 )) && return 1
+ [[ -f $cache ]] || return 1
+ local now mtime age
+ now=$(date +%s)
+ mtime=$(stat -c %Y "$cache" 2>/dev/null || stat -f %m "$cache" 2>/dev/null || echo 0)
+ age=$(( now - mtime ))
+ (( age < EMERGER_CACHE_TTL ))
+}
+
+pkg_detect() {
+ local m="$1" cache="${EMERGER_CACHE:-}/detected"
+ if _pkg_cache_fresh "$cache"; then
+ grep -q "^$m\$" "$cache" && return 0
+ grep -q "^!$m\$" "$cache" && return 1
+ fi
+ local rc
+ if declare -F plugin_is >/dev/null && plugin_is "$m"; then
+ plugin_detect "$m"; rc=$?
+ else
+ _pkg_detect_raw "$m"; rc=$?
+ fi
+ if (( rc == 0 )); then
+ [[ -n ${EMERGER_CACHE:-} ]] && printf '%s\n' "$m" >>"$cache" 2>/dev/null
+ return 0
+ else
+ [[ -n ${EMERGER_CACHE:-} ]] && printf '!%s\n' "$m" >>"$cache" 2>/dev/null
+ return 1
+ fi
+}
+
+pkg_icon() {
+ if (( UI_UNICODE )); then
+ case "$1" in
+ pacman|yay|paru) printf '\xf0\x9f\x8f\xb9' ;;
+ apt) printf '\xf0\x9f\x8c\x80' ;;
+ dnf|yum) printf '\xf0\x9f\xa4\xa0' ;;
+ zypper) printf '\xf0\x9f\xa6\x8e' ;;
+ apk) printf '\xe2\x9b\xb0' ;;
+ xbps) printf '\xf0\x9f\x95\xb3' ;;
+ emerge) printf '\xf0\x9f\x90\xa7' ;;
+ nix) printf '\xe2\x9d\x84' ;;
+ brew) printf '\xf0\x9f\x8d\xba' ;;
+ mas) printf '\xef\xa3\xbf' ;;
+ softwareupdate) printf '\xef\xa3\xbf' ;;
+ flatpak) printf '\xf0\x9f\x93\xa6' ;;
+ snap) printf '\xf0\x9f\x90\xa2' ;;
+ eopkg) printf '\xf0\x9f\xa7\xb1' ;;
+ fwupd) printf '\xf0\x9f\x94\xa7' ;;
+ rustup|cargo) printf '\xf0\x9f\xa6\x80' ;;
+ npm|pnpm) printf '\xf0\x9f\x93\x97' ;;
+ pip) printf '\xf0\x9f\x90\x8d' ;;
+ gem) printf '\xf0\x9f\x92\x8e' ;;
+ *)
+ if declare -F plugin_is >/dev/null && plugin_is "$1"; then
+ plugin_icon "$1"
+ else
+ printf '%s' "$(ui_glyph bullet)"
+ fi
+ ;;
+ esac
+ else
+ printf '%s' "$(ui_glyph bullet)"
+ fi
+}
+
+pkg_run() {
+ local mgr="$1" sudo_cmd="" rc=0
+ _PKG_CURRENT="$mgr"
+ if pkg_needs_sudo "$mgr"; then sudo_cmd="sudo"; fi
+
+ local ic est
+ ic=$(pkg_icon "$mgr")
+ est=$(estimate_for "$mgr")
+ ui_title "$ic $mgr${est:+ $est}"
+ log_info "starting $mgr"
+
+ # Route to plugin if registered.
+ if declare -F plugin_is >/dev/null && plugin_is "$mgr"; then
+ plugin_run "$mgr"; rc=$?
+ _PKG_CURRENT=""
+ (( rc == 0 )) && log_info "$mgr: ok" || log_error "$mgr: rc=$rc"
+ return $rc
+ fi
+
+ # Pacman can natively honor an ignore list.
+ local pacman_ignore=""
+ sys_has pacman && pacman_ignore=$(ignore_pacman_flag)
+
+ case "$mgr" in
+ pacman)
+ # shellcheck disable=SC2086
+ run_cmd "refresh databases" $sudo_cmd pacman -Syy --noconfirm $pacman_ignore || rc=$?
+ if (( ARG_DOWNLOAD_ONLY )); then
+ # shellcheck disable=SC2086
+ run_cmd "download only" $sudo_cmd pacman -Syuw --noconfirm $pacman_ignore || rc=$?
+ else
+ # shellcheck disable=SC2086
+ run_cmd "upgrade system" $sudo_cmd pacman -Syu --noconfirm $pacman_ignore || rc=$?
+ if sys_has paccache; then
+ run_cmd "clean package cache" $sudo_cmd paccache -rk2 || true
+ fi
+ fi
+ ;;
+ apt)
+ local apt_bin=apt-get
+ sys_has apt && apt_bin=apt
+ run_cmd "fix broken" $sudo_cmd "$apt_bin" --fix-broken install -y || rc=$?
+ run_cmd "update" $sudo_cmd "$apt_bin" update || rc=$?
+ if (( ARG_DOWNLOAD_ONLY )); then
+ local upflag=full-upgrade
+ [[ $apt_bin == apt-get ]] && upflag=dist-upgrade
+ run_cmd "$upflag --download-only" $sudo_cmd "$apt_bin" "$upflag" -y --download-only || rc=$?
+ else
+ if (( ARG_SECURITY )) && sys_has unattended-upgrade; then
+ run_cmd "security upgrade" $sudo_cmd unattended-upgrade -v || rc=$?
+ else
+ local upflag=full-upgrade
+ [[ $apt_bin == apt-get ]] && upflag=dist-upgrade
+ run_cmd "$upflag" $sudo_cmd "$apt_bin" "$upflag" -y || rc=$?
+ fi
+ run_cmd "autoremove" $sudo_cmd "$apt_bin" autoremove -y || rc=$?
+ run_cmd "autoclean" $sudo_cmd "$apt_bin" autoclean -y || rc=$?
+ run_cmd "clean" $sudo_cmd "$apt_bin" clean || rc=$?
+ fi
+ ;;
+ dnf)
+ local sec=""
+ (( ARG_SECURITY )) && sec="--security"
+ if (( ARG_DOWNLOAD_ONLY )); then
+ run_cmd "download only" $sudo_cmd dnf upgrade -y --downloadonly $sec || rc=$?
+ else
+ run_cmd "upgrade" $sudo_cmd dnf upgrade -y $sec || rc=$?
+ run_cmd "autoremove" $sudo_cmd dnf autoremove -y || rc=$?
+ run_cmd "clean all" $sudo_cmd dnf clean all || rc=$?
+ fi
+ ;;
+ yum)
+ run_cmd "update" $sudo_cmd yum update -y || rc=$?
+ run_cmd "clean all" $sudo_cmd yum clean all || rc=$?
+ ;;
+ zypper)
+ run_cmd "refresh" $sudo_cmd zypper --non-interactive refresh || rc=$?
+ if (( ARG_DOWNLOAD_ONLY )); then
+ run_cmd "download only" $sudo_cmd zypper --non-interactive update --download-only || rc=$?
+ elif (( ARG_SECURITY )); then
+ run_cmd "security patch" $sudo_cmd zypper --non-interactive patch --category security || rc=$?
+ else
+ run_cmd "update" $sudo_cmd zypper --non-interactive update || rc=$?
+ fi
+ run_cmd "clean" $sudo_cmd zypper clean || true
+ ;;
+ xbps)
+ run_cmd "sync & upgrade" $sudo_cmd xbps-install -Suy || rc=$?
+ run_cmd "remove orphans" $sudo_cmd xbps-remove -Oy || true
+ ;;
+ apk)
+ run_cmd "update" $sudo_cmd apk update || rc=$?
+ run_cmd "upgrade" $sudo_cmd apk upgrade || rc=$?
+ run_cmd "clean cache" $sudo_cmd apk cache clean || true
+ ;;
+ eopkg)
+ run_cmd "upgrade" $sudo_cmd eopkg upgrade -y || rc=$?
+ run_cmd "delete cache" $sudo_cmd eopkg delete-cache || true
+ ;;
+ emerge)
+ run_cmd "sync" $sudo_cmd emerge --sync --quiet || rc=$?
+ run_cmd "update world" $sudo_cmd emerge -uDN --quiet @world || rc=$?
+ run_cmd "depclean" $sudo_cmd emerge --depclean --quiet || true
+ ;;
+ nix)
+ if sys_has nixos-rebuild; then
+ run_cmd "nixos-rebuild switch --upgrade" $sudo_cmd nixos-rebuild switch --upgrade || rc=$?
+ run_cmd "collect garbage" $sudo_cmd nix-collect-garbage -d || true
+ else
+ run_cmd "channel update" nix-channel --update || rc=$?
+ run_cmd "upgrade" nix-env -u || rc=$?
+ run_cmd "collect garbage" nix-collect-garbage -d || true
+ fi
+ ;;
+ brew)
+ run_cmd "update" brew update || rc=$?
+ run_cmd "upgrade" brew upgrade || rc=$?
+ run_cmd "upgrade casks" brew upgrade --cask || true
+ run_cmd "cleanup" brew cleanup -s || true
+ ;;
+ mas)
+ run_cmd "mas upgrade" mas upgrade || rc=$?
+ ;;
+ softwareupdate)
+ if (( ARG_SECURITY )); then
+ run_cmd "install recommended" $sudo_cmd softwareupdate --install --recommended --agree-to-license || rc=$?
+ else
+ run_cmd "list" softwareupdate --list || true
+ run_cmd "install all" $sudo_cmd softwareupdate --install --all --agree-to-license --no-scan || rc=$?
+ fi
+ ;;
+ flatpak)
+ run_cmd "update" flatpak update -y --noninteractive || rc=$?
+ run_cmd "uninstall unused" flatpak uninstall --unused -y --noninteractive || true
+ ;;
+ snap)
+ run_cmd "refresh" $sudo_cmd snap refresh || rc=$?
+ ;;
+ yay)
+ # shellcheck disable=SC2086
+ run_cmd "AUR upgrade" yay -Syu --noconfirm $pacman_ignore || rc=$?
+ ;;
+ paru)
+ # shellcheck disable=SC2086
+ run_cmd "AUR upgrade" paru -Syu --noconfirm $pacman_ignore || rc=$?
+ ;;
+ fwupd)
+ run_cmd "refresh metadata" $sudo_cmd fwupdmgr refresh --force || rc=$?
+ run_cmd "firmware update" $sudo_cmd fwupdmgr update -y --no-reboot-check || true
+ ;;
+ rustup)
+ run_cmd "rustup update" rustup update || rc=$?
+ ;;
+ cargo)
+ run_cmd "cargo install-update" cargo install-update -a || rc=$?
+ ;;
+ npm)
+ run_cmd "npm update -g" npm update -g || rc=$?
+ ;;
+ pnpm)
+ run_cmd "pnpm -g update" pnpm -g update || rc=$?
+ ;;
+ pip)
+ local pip_bin=pip
+ sys_has pip3 && pip_bin=pip3
+ local flags="--user"
+ sys_is_root && flags=""
+ run_cmd "pip upgrade" bash -c "$pip_bin list --outdated --format=freeze $flags 2>/dev/null | cut -d= -f1 | xargs -r $pip_bin install -U $flags" || rc=$?
+ ;;
+ gem)
+ run_cmd "gem update" gem update || rc=$?
+ ;;
+ esac
+
+ _PKG_CURRENT=""
+ if (( rc == 0 )); then
+ log_info "$mgr: ok"
+ else
+ log_error "$mgr: rc=$rc"
+ fi
+ return $rc
+}
diff --git a/src/lib/plugins.sh b/src/lib/plugins.sh
new file mode 100644
index 0000000..24053c9
--- /dev/null
+++ b/src/lib/plugins.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+# External package-manager plugins.
+#
+# A plugin is a *.sh file in ~/.config/emerger/managers.d/ that defines,
+# at minimum, three functions named after its slug :
+#
+# pm__detect -> return 0 if the manager is present
+# pm__run -> perform refresh/upgrade/clean, return 0/non-0
+#
+# And optionally:
+#
+# pm__needs_sudo -> return 0 if the manager needs elevation
+# pm__parallel -> return 0 if it's safe to run in parallel
+# pm__dev -> return 0 if it belongs to --dev
+# pm__icon -> print a short glyph
+#
+# The plugin must also declare its slug via:
+#
+# PM_PLUGIN_SLUG=
+#
+# when sourced. Example plugin at share/plugins/example.sh.
+
+PLUGIN_SLUGS=()
+
+plugins_load() {
+ local dir="$EMERGER_CONFIG/managers.d"
+ [[ -d $dir ]] || return 0
+ local f slug
+ for f in "$dir"/*.sh; do
+ [[ -e $f ]] || continue
+ PM_PLUGIN_SLUG=""
+ # shellcheck disable=SC1090
+ source "$f" || { ui_warn "plugin $f failed to load"; continue; }
+ slug="$PM_PLUGIN_SLUG"
+ if [[ -z $slug ]]; then
+ ui_warn "plugin $f did not set PM_PLUGIN_SLUG"; continue
+ fi
+ if ! declare -F "pm_${slug}_detect" >/dev/null; then
+ ui_warn "plugin $slug: missing pm_${slug}_detect"; continue
+ fi
+ if ! declare -F "pm_${slug}_run" >/dev/null; then
+ ui_warn "plugin $slug: missing pm_${slug}_run"; continue
+ fi
+ PLUGIN_SLUGS+=("$slug")
+ PKG_MANAGERS+=("$slug")
+ done
+}
+
+plugin_is() {
+ local name="$1" s
+ for s in "${PLUGIN_SLUGS[@]}"; do
+ [[ $s == "$name" ]] && return 0
+ done
+ return 1
+}
+
+plugin_needs_sudo() {
+ declare -F "pm_${1}_needs_sudo" >/dev/null && "pm_${1}_needs_sudo"
+}
+plugin_is_parallel() {
+ declare -F "pm_${1}_parallel" >/dev/null && "pm_${1}_parallel"
+}
+plugin_is_dev() {
+ declare -F "pm_${1}_dev" >/dev/null && "pm_${1}_dev"
+}
+plugin_detect() {
+ "pm_${1}_detect"
+}
+plugin_run() {
+ "pm_${1}_run"
+}
+plugin_icon() {
+ if declare -F "pm_${1}_icon" >/dev/null; then
+ "pm_${1}_icon"
+ else
+ printf '%s' "$(ui_glyph bullet)"
+ fi
+}
diff --git a/src/lib/profiles.sh b/src/lib/profiles.sh
new file mode 100644
index 0000000..7f8f773
--- /dev/null
+++ b/src/lib/profiles.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Profile loader. Profiles are bash snippets that set ARG_* defaults.
+# Search order:
+# 1) $EMERGER_CONFIG/profiles.d/.sh (user)
+# 2) $EMERGER_ROOT/share/profiles/.sh (shipped)
+
+profile_load() {
+ local name="$1"
+ [[ -z $name ]] && return 0
+ if [[ ! $name =~ ^[A-Za-z0-9._-]+$ ]]; then
+ printf 'Invalid profile name: "%s" (allowed: letters, digits, dot, dash, underscore)\n' "$name" >&2
+ exit 2
+ fi
+ local candidates=(
+ "$EMERGER_CONFIG/profiles.d/${name}.sh"
+ "$EMERGER_ROOT/share/profiles/${name}.sh"
+ )
+ local f
+ for f in "${candidates[@]}"; do
+ if [[ -f $f ]]; then
+ # shellcheck disable=SC1090
+ source "$f"
+ log_info "profile loaded: $name ($f)"
+ return 0
+ fi
+ done
+ printf 'Profile "%s" not found. Looked in:\n' "$name" >&2
+ printf ' %s\n' "${candidates[@]}" >&2
+ exit 2
+}
+
+profile_list() {
+ ui_title "Available profiles"
+ local seen=() p n
+ for dir in "$EMERGER_CONFIG/profiles.d" "$EMERGER_ROOT/share/profiles"; do
+ [[ -d $dir ]] || continue
+ for p in "$dir"/*.sh; do
+ [[ -f $p ]] || continue
+ n=$(basename "$p" .sh)
+ local dup=0 s
+ for s in "${seen[@]:-}"; do [[ $s == "$n" ]] && dup=1; done
+ (( dup )) && continue
+ seen+=("$n")
+ local desc=""
+ desc=$(grep -m1 '^# description:' "$p" 2>/dev/null | sed 's/^# description:[[:space:]]*//' || true)
+ printf ' %s%s%s %s%s%s\n' "$C_CYAN" "$n" "$C_RESET" "$C_GRAY" "$desc" "$C_RESET"
+ done
+ done
+}
diff --git a/src/lib/progress.sh b/src/lib/progress.sh
new file mode 100644
index 0000000..43710ff
--- /dev/null
+++ b/src/lib/progress.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Extract a short one-line stat from a command's captured output.
+# Used to produce a collapsed summary at end of each step.
+
+progress_summarize() {
+ local mgr="$1" file="$2"
+ [[ -f $file ]] || { printf ''; return; }
+ case "$mgr" in
+ apt)
+ awk '/[0-9]+ upgraded/{for(i=1;i<=NF;i++)if($i=="upgraded,"){u=$(i-1)} for(i=1;i<=NF;i++)if($i=="newly"){n=$(i-1)} for(i=1;i<=NF;i++)if($i=="remove."){r=$(i-1)} printf "%s upgraded, %s new, %s removed", u+0, n+0, r+0; exit}' "$file"
+ ;;
+ pacman|yay|paru)
+ local pkgs
+ pkgs=$(grep -E '^Packages \([0-9]+\)' "$file" | tail -n1 | sed -E 's/^Packages \(([0-9]+)\).*/\1/')
+ [[ -n $pkgs ]] && printf '%s packages' "$pkgs"
+ ;;
+ dnf|yum)
+ awk '/^Upgrade +[0-9]+ Package/{print $2" upgraded"; exit} /^Install +[0-9]+ Package/{ins=$2} END{if(ins)print ins" installed"}' "$file"
+ ;;
+ flatpak)
+ grep -cE '^Updating|^Installing' "$file" | awk '{if($1>0)print $1" refreshed"}'
+ ;;
+ snap)
+ grep -cE 'refreshed' "$file" | awk '{if($1>0)print $1" refreshed"}'
+ ;;
+ *) : ;;
+ esac
+}
+
+progress_highlight() {
+ # Colorize common error keywords in a stream.
+ sed -E \
+ -e "s/(ERROR|Error|error)/$(printf '\033[31m')\1$(printf '\033[0m')/g" \
+ -e "s/(WARNING|Warning|warning|WARN)/$(printf '\033[33m')\1$(printf '\033[0m')/g" \
+ -e "s/^(E: .*)/$(printf '\033[31m')\1$(printf '\033[0m')/g" \
+ -e "s/^(W: .*)/$(printf '\033[33m')\1$(printf '\033[0m')/g"
+}
diff --git a/src/lib/reboot.sh b/src/lib/reboot.sh
new file mode 100644
index 0000000..ef31caa
--- /dev/null
+++ b/src/lib/reboot.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Detect whether the system needs a reboot after updates.
+
+REBOOT_NEEDED=0
+REBOOT_REASON=""
+
+reboot_check() {
+ REBOOT_NEEDED=0; REBOOT_REASON=""
+ if [[ -f /var/run/reboot-required ]]; then
+ REBOOT_NEEDED=1
+ REBOOT_REASON="/var/run/reboot-required exists"
+ if [[ -f /var/run/reboot-required.pkgs ]]; then
+ REBOOT_REASON+=" ($(wc -l /dev/null 2>&1; then
+ REBOOT_NEEDED=1
+ REBOOT_REASON="needs-restarting -r says so"
+ return
+ fi
+ fi
+ if sys_has dnf && dnf -q needs-restarting -r >/dev/null 2>&1; then
+ :
+ elif sys_has dnf; then
+ REBOOT_NEEDED=1; REBOOT_REASON="dnf needs-restarting"; return
+ fi
+ # Kernel version mismatch (running vs latest installed)
+ local running latest=""
+ running=$(uname -r)
+ if sys_has pacman; then
+ latest=$(pacman -Q linux 2>/dev/null | awk '{print $2}')
+ elif sys_has dpkg-query; then
+ latest=$(dpkg-query -W -f='${Version}' linux-image-generic 2>/dev/null || true)
+ fi
+ if [[ -n $latest ]] && [[ $running != *"$latest"* ]] && [[ -n $latest ]]; then
+ : # informational only; don't force reboot just for this
+ fi
+}
+
+reboot_advisory() {
+ (( REBOOT_NEEDED )) || return 0
+ printf '\n %s%s REBOOT RECOMMENDED%s %s%s%s\n' \
+ "$C_YELLOW$C_BOLD" "$(ui_glyph warn)" "$C_RESET" \
+ "$C_GRAY" "$REBOOT_REASON" "$C_RESET"
+ printf ' %srun:%s sudo reboot\n' "$C_DIM" "$C_RESET"
+}
diff --git a/src/lib/report.sh b/src/lib/report.sh
new file mode 100644
index 0000000..d070af3
--- /dev/null
+++ b/src/lib/report.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Export the last run's summary + diff as Markdown.
+
+report_export() {
+ local out="${1:-$EMERGER_STATE/last-report.md}"
+ local hist="$EMERGER_STATE/history.jsonl"
+ {
+ printf '# eMerger report\n\n'
+ printf '- host: `%s`\n' "$(hostname 2>/dev/null || echo '?')"
+ printf '- date: `%s`\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ printf '- distro: `%s`\n' "$(sys_distro)"
+ printf '- version: `eMerger %s`\n\n' "${EMERGER_VERSION:-?}"
+ if [[ -f $hist ]]; then
+ printf '## Last run\n\n```json\n'
+ tail -n 1 "$hist"
+ printf '```\n\n'
+ fi
+ if (( ${#SUMMARY_MANAGERS[@]:-0} > 0 )); then
+ printf '## Managers\n\n'
+ local i
+ for i in "${!SUMMARY_MANAGERS[@]}"; do
+ printf '- `%s` - **%s**\n' "${SUMMARY_MANAGERS[i]}" "${SUMMARY_RESULTS[i]}"
+ done
+ printf '\n'
+ fi
+ if [[ -s $DIFF_LAST ]]; then
+ printf '## Package changes\n\n'
+ printf '| kind | manager | package | version |\n|---|---|---|---|\n'
+ awk -F'\t' '{printf "| %s | %s | %s | %s |\n", $1, $2, $3, $4}' "$DIFF_LAST"
+ printf '\n'
+ fi
+ if (( REBOOT_NEEDED )); then
+ printf '> **Reboot recommended** - %s\n\n' "$REBOOT_REASON"
+ fi
+ } >"$out"
+ ui_ok "Report written to $out"
+}
diff --git a/src/lib/resume.sh b/src/lib/resume.sh
new file mode 100644
index 0000000..0a6aa11
--- /dev/null
+++ b/src/lib/resume.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Resume support: remember completed managers so an interrupted run can continue.
+
+: "${RESUME_FILE:=$EMERGER_STATE/resume}"
+
+resume_load() {
+ RESUME_DONE=()
+ [[ -f $RESUME_FILE ]] || return 0
+ local line
+ while IFS= read -r line || [[ -n $line ]]; do
+ RESUME_DONE+=("$line")
+ done <"$RESUME_FILE"
+}
+
+resume_has() {
+ local m="$1" x
+ for x in "${RESUME_DONE[@]:-}"; do
+ [[ $x == "$m" ]] && return 0
+ done
+ return 1
+}
+
+resume_mark() {
+ local m="$1"
+ printf '%s\n' "$m" >>"$RESUME_FILE"
+}
+
+resume_clear() {
+ rm -f "$RESUME_FILE"
+}
diff --git a/src/lib/retry.sh b/src/lib/retry.sh
new file mode 100644
index 0000000..31d61d2
--- /dev/null
+++ b/src/lib/retry.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Retry a command with exponential backoff on transient failures.
+# Detects network errors by scanning output for common keywords.
+
+retry_cmd() {
+ local max="${1:-3}"; shift
+ local attempt=1 rc=0
+ while :; do
+ "$@"
+ rc=$?
+ (( rc == 0 )) && return 0
+ if (( attempt >= max )); then return "$rc"; fi
+ local backoff=$(( attempt * 3 ))
+ log_warn "retry attempt $attempt/$max for: $* (sleeping ${backoff}s)"
+ sleep "$backoff"
+ attempt=$(( attempt + 1 ))
+ done
+}
+
+# Pattern tester: returns 0 if output looks like a transient network failure.
+retry_is_transient() {
+ local text="$1"
+ local pat='(Temporary failure|Could not resolve|Connection (timed out|reset|refused)|network is unreachable|503 |502 |504 |failed to fetch|EOF occurred|timed out|Try again later)'
+ [[ $text =~ $pat ]]
+}
diff --git a/src/lib/run.sh b/src/lib/run.sh
new file mode 100644
index 0000000..d032a16
--- /dev/null
+++ b/src/lib/run.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Command runner: dry-run aware, live-log monitor, retry on transient errors,
+# one-line collapsed summary on success.
+
+: "${DRY_RUN:=0}"
+: "${UI_VERBOSE:=0}"
+: "${RETRY_MAX:=2}"
+
+run_cmd() {
+ local label="$1"; shift
+ if (( DRY_RUN )); then
+ local joined; joined=$(IFS=' '; printf '%s' "$*")
+ ui_sub "[dry-run] $joined"
+ return 0
+ fi
+ local rc=0
+ if (( UI_VERBOSE )); then
+ ui_sub "$label"
+ "$@"
+ rc=$?
+ if (( rc == 0 )); then
+ return 0
+ else
+ ui_fail "$label" "$rc"
+ log_error "$label: rc=$rc"
+ return "$rc"
+ fi
+ fi
+
+ local tmp; tmp=$(mktemp -t emerger.XXXXXX)
+ local attempt=1
+ while :; do
+ : >"$tmp"
+ ui_monitor_start "$label" "$tmp"
+ "$@" >"$tmp" 2>&1
+ rc=$?
+ ui_monitor_stop
+ if (( rc == 0 )); then
+ local stat=""
+ stat=$(progress_summarize "${_PKG_CURRENT:-}" "$tmp")
+ if [[ -n $stat ]]; then
+ ui_done "$label" "$stat"
+ else
+ ui_done "$label"
+ fi
+ rm -f "$tmp"
+ return 0
+ fi
+ local out; out=$(cat "$tmp" 2>/dev/null || true)
+ if retry_is_transient "$out" && (( attempt < RETRY_MAX )); then
+ ui_warn "$label: transient failure, retrying ($attempt/$RETRY_MAX)"
+ log_warn "$label: retry $attempt (rc=$rc)"
+ attempt=$(( attempt + 1 ))
+ sleep $(( attempt * 2 ))
+ continue
+ fi
+ ui_fail "$label" "$rc"
+ log_error "$label: rc=$rc"
+ tail -n 20 "$tmp" | progress_highlight >&2
+ log_error "$(tail -n 50 "$tmp")"
+ rm -f "$tmp"
+ return "$rc"
+ done
+}
diff --git a/src/lib/snapshot.sh b/src/lib/snapshot.sh
new file mode 100644
index 0000000..993e950
--- /dev/null
+++ b/src/lib/snapshot.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+# Pre-update filesystem snapshot via snapper, timeshift or btrfs.
+
+snapshot_create() {
+ ui_title "Snapshot"
+ local desc="eMerger pre-update $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] would create snapshot: $desc"
+ return 0
+ fi
+ if sys_has snapper && snapper list-configs 2>/dev/null | grep -q '^root'; then
+ if sudo snapper -c root create --description "$desc" --type single --cleanup-algorithm number; then
+ ui_ok "snapper snapshot created"
+ return 0
+ fi
+ fi
+ if sys_has timeshift; then
+ if sudo timeshift --create --comments "$desc" --tags D >/dev/null 2>&1; then
+ ui_ok "timeshift snapshot created"
+ return 0
+ fi
+ fi
+ if sys_has btrfs && mount | grep -q 'on / type btrfs'; then
+ local snap_dir="/.snapshots/emerger"
+ sudo mkdir -p "$snap_dir" 2>/dev/null || true
+ local name="$snap_dir/$(date +%Y-%m-%dT%H-%M-%S)"
+ if sudo btrfs subvolume snapshot -r / "$name" >/dev/null 2>&1; then
+ ui_ok "btrfs snapshot: $name"
+ return 0
+ fi
+ fi
+ ui_warn "No snapshot tool available (snapper/timeshift/btrfs); continuing without."
+ return 1
+}
+
+# Roll back to the most recent eMerger-created snapshot.
+# Supports snapper (native rollback) and timeshift (--restore).
+# For raw btrfs snapshots the user must swap subvolumes manually - we print
+# the path of the latest one and refuse to touch /.
+snapshot_rollback() {
+ ui_title "Rollback"
+ if sys_has snapper && snapper list-configs 2>/dev/null | grep -q '^root'; then
+ local last
+ last=$(snapper -c root list 2>/dev/null | awk -F'|' '/eMerger pre-update/ {gsub(/ /,"",$2); last=$2} END{print last}')
+ if [[ -n $last ]]; then
+ ui_muted "Rolling back to snapper snapshot #$last"
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] would run: snapper -c root rollback $last"
+ return 0
+ fi
+ if sudo snapper -c root rollback "$last"; then
+ ui_ok "Rollback queued. Reboot to apply."
+ return 0
+ fi
+ ui_err "snapper rollback failed"; return 1
+ fi
+ ui_warn "No eMerger snapper snapshot found."
+ fi
+ if sys_has timeshift; then
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] would run: timeshift --restore (interactive)"
+ return 0
+ fi
+ ui_muted "Launching timeshift --restore"
+ sudo timeshift --restore
+ return $?
+ fi
+ if sys_has btrfs && mount | grep -q 'on / type btrfs'; then
+ local snap_dir="/.snapshots/emerger" latest
+ if [[ -d $snap_dir ]]; then
+ latest=$(ls -1t "$snap_dir" 2>/dev/null | head -n1)
+ if [[ -n $latest ]]; then
+ ui_warn "Latest btrfs snapshot: $snap_dir/$latest"
+ ui_warn "Automatic btrfs rollback is not performed - swap subvolumes manually."
+ return 1
+ fi
+ fi
+ fi
+ ui_err "No rollback mechanism available."
+ return 1
+}
diff --git a/src/lib/summary.sh b/src/lib/summary.sh
new file mode 100644
index 0000000..167415c
--- /dev/null
+++ b/src/lib/summary.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# Final summary: box banner, reboot advisory, optional package diff summary.
+
+: "${SUMMARY_FREED:=0}"
+SUMMARY_MANAGERS=()
+SUMMARY_RESULTS=()
+SUMMARY_ERRORS=0
+
+_fmt_bytes() {
+ local kb="$1"
+ if sys_has numfmt; then
+ numfmt --to=iec --suffix=B --format='%.1f' $(( kb * 1024 )) 2>/dev/null && return
+ fi
+ printf '%s KiB' "$kb"
+}
+
+summary_json() {
+ local duration="$1"
+ local i first=1
+ printf '{"ts":"%s","duration":%d,"freed_kb":%d,"errors":%d,"reboot":%d,"managers":[' \
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$duration" "$SUMMARY_FREED" "$SUMMARY_ERRORS" "${REBOOT_NEEDED:-0}"
+ for i in "${!SUMMARY_MANAGERS[@]}"; do
+ (( first )) || printf ','
+ first=0
+ printf '{"name":"%s","result":"%s"}' "${SUMMARY_MANAGERS[i]}" "${SUMMARY_RESULTS[i]}"
+ done
+ printf ']}\n'
+}
+
+summary_print() {
+ local duration="$1"
+
+ if (( ARG_JSON )); then
+ reboot_check
+ summary_json "$duration"
+ _persist_history "$duration"
+ return
+ fi
+
+ (( QUIET_LEVEL >= 3 )) && return
+
+ local min=$(( duration / 60 )) s=$(( duration % 60 ))
+ local ok_count=0 fail_count=0 i
+ for i in "${!SUMMARY_MANAGERS[@]}"; do
+ if [[ ${SUMMARY_RESULTS[i]} == ok ]]; then
+ ok_count=$(( ok_count + 1 ))
+ else
+ fail_count=$(( fail_count + 1 ))
+ fi
+ done
+
+ local mgr_list=""
+ if (( ${#SUMMARY_MANAGERS[@]} > 0 )); then
+ for i in "${!SUMMARY_MANAGERS[@]}"; do
+ if [[ ${SUMMARY_RESULTS[i]} == ok ]]; then
+ mgr_list+="${C_GREEN}$(ui_glyph check)${C_RESET} ${SUMMARY_MANAGERS[i]} "
+ else
+ mgr_list+="${C_RED}$(ui_glyph cross)${C_RESET} ${SUMMARY_MANAGERS[i]} "
+ fi
+ done
+ fi
+
+ local dur_line="duration: ${min}m${s}s"
+ local freed_line=""
+ (( SUMMARY_FREED > 0 )) && freed_line="freed: $(_fmt_bytes "$SUMMARY_FREED")"
+ local changed_line=""
+ local changed_n; changed_n=$(diff_count_changed 2>/dev/null || echo 0)
+ (( changed_n > 0 )) && changed_line="pkg diff: ${changed_n} changes (up --changed)"
+ local err_line
+ if (( SUMMARY_ERRORS > 0 )); then
+ err_line="${C_YELLOW}${SUMMARY_ERRORS} error(s) - up --errors${C_RESET}"
+ else
+ err_line="${C_GREEN}no errors${C_RESET}"
+ fi
+
+ if (( QUIET_LEVEL >= 2 )); then
+ if (( SUMMARY_ERRORS > 0 )); then
+ printf '%s/%s managers ok, %s error(s), %dm%02ds\n' \
+ "$ok_count" "$(( ok_count + fail_count ))" "$SUMMARY_ERRORS" "$min" "$s"
+ else
+ printf '%s managers ok, %dm%02ds\n' "$ok_count" "$min" "$s"
+ fi
+ _persist_history "$duration"
+ reboot_check; reboot_advisory
+ return
+ fi
+
+ local lines=("$dur_line")
+ [[ -n $freed_line ]] && lines+=("$freed_line")
+ [[ -n $changed_line ]] && lines+=("$changed_line")
+ lines+=("$err_line")
+ [[ -n $mgr_list ]] && lines=("$mgr_list" "" "${lines[@]}")
+
+ ui_box "eMerger summary" "${lines[@]}"
+
+ reboot_check
+ reboot_advisory
+
+ _persist_history "$duration"
+}
+
+_persist_history() {
+ local duration="$1"
+ {
+ printf '{"ts":"%s","duration":%d,"freed_kb":%d,"errors":%d,"reboot":%d,"managers":[' \
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$duration" "$SUMMARY_FREED" "$SUMMARY_ERRORS" "${REBOOT_NEEDED:-0}"
+ local first=1 i
+ for i in "${!SUMMARY_MANAGERS[@]}"; do
+ (( first )) || printf ','
+ first=0
+ printf '{"name":"%s","result":"%s"}' "${SUMMARY_MANAGERS[i]}" "${SUMMARY_RESULTS[i]}"
+ done
+ printf ']}\n'
+ } >>"$EMERGER_STATE/history.jsonl" 2>/dev/null || true
+
+ local hist="$EMERGER_STATE/history.jsonl"
+ if [[ -f $hist ]] && [[ $(wc -l <"$hist") -gt 500 ]]; then
+ tail -n 500 "$hist" >"$hist.tmp" && mv "$hist.tmp" "$hist"
+ fi
+}
+
+show_errors() {
+ local log="$EMERGER_LOG"
+ if [[ ! -f $log ]]; then
+ ui_ok "No log yet."
+ return 0
+ fi
+ local count
+ count=$(grep -c '|ERROR|' "$log" 2>/dev/null || true)
+ : "${count:=0}"
+ if (( count == 0 )); then
+ ui_ok "No errors logged."
+ else
+ ui_warn "$count error line(s) in $log:"
+ grep '|ERROR|' "$log" | tail -n 30 | progress_highlight | sed 's/^/ /'
+ fi
+}
+
+show_history() {
+ local hist="$EMERGER_STATE/history.jsonl"
+ if [[ ! -f $hist ]]; then
+ ui_muted "No history yet."
+ return
+ fi
+ ui_title "Recent runs"
+ tail -n 10 "$hist" | while IFS= read -r line; do
+ local ts dur err reboot
+ ts=$(printf '%s' "$line" | sed -n 's/.*"ts":"\([^"]*\)".*/\1/p')
+ dur=$(printf '%s' "$line" | sed -n 's/.*"duration":\([0-9]*\).*/\1/p')
+ err=$(printf '%s' "$line" | sed -n 's/.*"errors":\([0-9]*\).*/\1/p')
+ reboot=$(printf '%s' "$line" | sed -n 's/.*"reboot":\([0-9]*\).*/\1/p')
+ local tag=""
+ [[ ${reboot:-0} -gt 0 ]] && tag=" ${C_YELLOW}reboot${C_RESET}"
+ if [[ ${err:-0} -gt 0 ]]; then
+ printf ' %s%s%s %s %ss %serrors=%s%s%s\n' "$C_RED" "$(ui_glyph cross)" "$C_RESET" "$ts" "$dur" "$C_YELLOW" "$err" "$C_RESET" "$tag"
+ else
+ printf ' %s%s%s %s %ss%s\n' "$C_GREEN" "$(ui_glyph check)" "$C_RESET" "$ts" "$dur" "$tag"
+ fi
+ done
+}
diff --git a/src/lib/sys.sh b/src/lib/sys.sh
new file mode 100644
index 0000000..9f504ca
--- /dev/null
+++ b/src/lib/sys.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# System detection helpers.
+
+sys_has() { command -v "$1" >/dev/null 2>&1; }
+sys_is_macos() { [[ $(uname -s) == Darwin ]]; }
+sys_is_linux() { [[ $(uname -s) == Linux ]]; }
+sys_brew_prefix() { command -v brew >/dev/null 2>&1 && brew --prefix 2>/dev/null; }
+
+sys_os() {
+ case "$(uname -s)" in
+ Linux) echo linux ;;
+ Darwin) echo macos ;;
+ MINGW*|MSYS*|CYGWIN*) echo windows ;;
+ *) echo unknown ;;
+ esac
+}
+
+sys_distro() {
+ if [[ -f /etc/os-release ]]; then
+ # shellcheck disable=SC1091
+ . /etc/os-release
+ echo "${PRETTY_NAME:-${NAME:-unknown}}"
+ else
+ uname -sr
+ fi
+}
+
+sys_shell_rc() {
+ local sh
+ sh=$(basename "${SHELL:-bash}")
+ case "$sh" in
+ bash) echo "$HOME/.bashrc" ;;
+ zsh) echo "${ZDOTDIR:-$HOME}/.zshrc" ;;
+ fish) echo "$HOME/.config/fish/config.fish" ;;
+ *) echo "$HOME/.${sh}rc" ;;
+ esac
+}
+
+sys_on_battery() {
+ [[ -d /sys/class/power_supply ]] || return 1
+ local ac found=0
+ for ac in /sys/class/power_supply/A{C,DP,C0,DP0}*/online; do
+ [[ -f $ac ]] || continue
+ found=1
+ [[ $(cat "$ac" 2>/dev/null) == 1 ]] && return 1
+ done
+ (( found )) && return 0
+ return 1
+}
+
+sys_battery_percent() {
+ local cap
+ for cap in /sys/class/power_supply/BAT*/capacity; do
+ [[ -f $cap ]] && { cat "$cap"; return; }
+ done
+ echo 100
+}
+
+sys_is_root() { [[ ${EUID:-$(id -u)} -eq 0 ]]; }
diff --git a/src/lib/tui.sh b/src/lib/tui.sh
new file mode 100644
index 0000000..13cdb8c
--- /dev/null
+++ b/src/lib/tui.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Interactive TUI using gum or whiptail, falling back to a simple read loop.
+
+tui_menu() {
+ local choice
+ if sys_has gum; then
+ choice=$(gum choose --header="eMerger" \
+ "Update all" \
+ "Dry run" \
+ "Security updates only" \
+ "Include dev tools (rust/npm/pip...)" \
+ "Show errors" \
+ "Show history" \
+ "Self-update" \
+ "Quit") || return 0
+ elif sys_has whiptail; then
+ choice=$(whiptail --title "eMerger" --menu "Choose an action" 18 60 8 \
+ "Update all" "full system update" \
+ "Dry run" "simulate without changes" \
+ "Security updates only" "apt/dnf/zypper security" \
+ "Include dev tools (rust/npm/pip...)" "also update toolchains" \
+ "Show errors" "read log errors" \
+ "Show history" "last 10 runs" \
+ "Self-update" "git pull eMerger" \
+ "Quit" "exit" \
+ 3>&1 1>&2 2>&3) || return 0
+ else
+ printf '\n Install "gum" or "whiptail" for a nicer menu.\n\n'
+ printf ' 1) Update all\n 2) Dry run\n 3) Security only\n 4) + dev tools\n 5) Errors\n 6) History\n 7) Self-update\n q) Quit\n\n'
+ local pick
+ read -r -p " > " pick
+ case "$pick" in
+ 1) choice="Update all" ;;
+ 2) choice="Dry run" ;;
+ 3) choice="Security updates only" ;;
+ 4) choice="Include dev tools (rust/npm/pip...)" ;;
+ 5) choice="Show errors" ;;
+ 6) choice="Show history" ;;
+ 7) choice="Self-update" ;;
+ *) return 0 ;;
+ esac
+ fi
+
+ case "$choice" in
+ "Update all") ARG_YES=1 ;;
+ "Dry run") ARG_DRY=1; DRY_RUN=1 ;;
+ "Security updates only") ARG_SECURITY=1; ARG_YES=1 ;;
+ "Include dev tools"*) ARG_DEV=1; ARG_YES=1 ;;
+ "Show errors") show_errors; exit 0 ;;
+ "Show history") show_history; exit 0 ;;
+ "Self-update") self_update "$EMERGER_ROOT"; exit $? ;;
+ *) exit 0 ;;
+ esac
+}
diff --git a/src/lib/ui.sh b/src/lib/ui.sh
new file mode 100644
index 0000000..7fc9228
--- /dev/null
+++ b/src/lib/ui.sh
@@ -0,0 +1,220 @@
+#!/usr/bin/env bash
+# UI primitives: colors, glyphs, boxes, steps, live log monitor, box banner.
+
+: "${ARG_NO_EMOJI:=0}"
+: "${QUIET_LEVEL:=0}"
+
+_ui_detect_light_bg() {
+ # Parse COLORFGBG="fg;bg". A low bg value (0-6) = dark theme, 7+ = light.
+ [[ -z ${COLORFGBG:-} ]] && { UI_LIGHT_BG=0; return; }
+ local bg="${COLORFGBG##*;}"
+ if [[ $bg =~ ^[0-9]+$ ]] && (( bg >= 7 )); then
+ UI_LIGHT_BG=1
+ else
+ UI_LIGHT_BG=0
+ fi
+}
+
+_ui_init() {
+ local colors=0
+ _ui_detect_light_bg
+ if [[ -n ${NO_COLOR:-} ]] || [[ ${TERM:-} == dumb ]] || ! [[ -t 1 ]]; then
+ C_RESET="" C_BOLD="" C_DIM=""
+ C_RED="" C_GREEN="" C_YELLOW="" C_BLUE="" C_MAGENTA="" C_CYAN="" C_GRAY=""
+ UI_TTY=0
+ else
+ command -v tput >/dev/null 2>&1 && colors=$(tput colors 2>/dev/null || echo 0)
+ C_RESET=$'\e[0m'; C_BOLD=$'\e[1m'; C_DIM=$'\e[2m'
+ if (( colors >= 256 )); then
+ if (( UI_LIGHT_BG )); then
+ C_RED=$'\e[38;5;160m'; C_GREEN=$'\e[38;5;28m'; C_YELLOW=$'\e[38;5;136m'
+ C_BLUE=$'\e[38;5;26m'; C_MAGENTA=$'\e[38;5;90m'; C_CYAN=$'\e[38;5;30m'
+ C_GRAY=$'\e[38;5;240m'
+ else
+ C_RED=$'\e[38;5;203m'; C_GREEN=$'\e[38;5;114m'; C_YELLOW=$'\e[38;5;221m'
+ C_BLUE=$'\e[38;5;39m'; C_MAGENTA=$'\e[38;5;176m'; C_CYAN=$'\e[38;5;81m'
+ C_GRAY=$'\e[38;5;245m'
+ fi
+ else
+ C_RED=$'\e[31m'; C_GREEN=$'\e[32m'; C_YELLOW=$'\e[33m'
+ C_BLUE=$'\e[34m'; C_MAGENTA=$'\e[35m'; C_CYAN=$'\e[36m'; C_GRAY=$'\e[37m'
+ fi
+ UI_TTY=1
+ fi
+ if (( ARG_NO_EMOJI )); then
+ UI_UNICODE=0
+ elif [[ ${LANG:-} =~ [Uu][Tt][Ff]-?8 ]] || [[ ${LC_ALL:-} =~ [Uu][Tt][Ff]-?8 ]] || [[ ${LC_CTYPE:-} =~ [Uu][Tt][Ff]-?8 ]]; then
+ UI_UNICODE=1
+ else
+ UI_UNICODE=0
+ fi
+}
+_ui_init
+
+ui_reinit() { _ui_init; }
+
+ui_glyph() {
+ if (( UI_UNICODE )); then
+ case "$1" in
+ check) printf '\xe2\x9c\x94' ;;
+ cross) printf '\xe2\x9c\x96' ;;
+ arrow) printf '\xe2\x96\xb6' ;;
+ info) printf '\xe2\x84\xb9' ;;
+ warn) printf '\xe2\x9a\xa0' ;;
+ bullet) printf '\xe2\x80\xa2' ;;
+ hline) printf '\xe2\x94\x80' ;;
+ dot) printf '\xc2\xb7' ;;
+ tl) printf '\xe2\x95\xad' ;;
+ tr) printf '\xe2\x95\xae' ;;
+ bl) printf '\xe2\x95\xb0' ;;
+ br) printf '\xe2\x95\xaf' ;;
+ v) printf '\xe2\x94\x82' ;;
+ esac
+ else
+ case "$1" in
+ check) printf '[OK]' ;;
+ cross) printf '[X]' ;;
+ arrow) printf '>' ;;
+ info) printf 'i' ;;
+ warn) printf '!' ;;
+ bullet) printf '*' ;;
+ hline) printf '-' ;;
+ dot) printf '.' ;;
+ tl|tr|bl|br) printf '+' ;;
+ v) printf '|' ;;
+ esac
+ fi
+}
+
+ui_width() {
+ local w
+ w=$(tput cols 2>/dev/null || echo 80)
+ (( w > 0 )) || w=80
+ printf '%d' "$w"
+}
+
+ui_hr() {
+ (( QUIET_LEVEL >= 2 )) && return
+ local w ch line
+ w=$(ui_width); (( w > 80 )) && w=80
+ ch=$(ui_glyph hline)
+ printf -v line '%*s' "$w" ''
+ printf '%s%s%s\n' "$C_GRAY" "${line// /$ch}" "$C_RESET"
+}
+
+ui_title() { (( QUIET_LEVEL >= 2 )) && return; printf '\n%s%s %s%s\n' "$C_CYAN$C_BOLD" "$(ui_glyph arrow)" "$*" "$C_RESET"; ui_hr; }
+ui_info() { (( QUIET_LEVEL >= 2 )) && return; printf ' %s%s%s %s\n' "$C_BLUE" "$(ui_glyph info)" "$C_RESET" "$*"; }
+ui_ok() { (( QUIET_LEVEL >= 2 )) && return; printf ' %s%s%s %s\n' "$C_GREEN" "$(ui_glyph check)" "$C_RESET" "$*"; }
+ui_warn() { printf ' %s%s%s %s\n' "$C_YELLOW" "$(ui_glyph warn)" "$C_RESET" "$*"; }
+ui_err() { printf ' %s%s%s %s\n' "$C_RED$C_BOLD" "$(ui_glyph cross)" "$C_RESET" "$*" >&2; }
+ui_muted() { (( QUIET_LEVEL >= 1 )) && return; printf ' %s%s%s\n' "$C_DIM$C_GRAY" "$*" "$C_RESET"; }
+ui_step() { (( QUIET_LEVEL >= 2 )) && return; printf '\n%s[%d/%d]%s %s %s%s%s\n' "$C_MAGENTA" "$1" "$2" "$C_RESET" "$(ui_glyph arrow)" "$C_BOLD" "$3" "$C_RESET"; }
+ui_sub() { (( QUIET_LEVEL >= 1 )) && return; printf ' %s%s%s %s\n' "$C_GRAY" "$(ui_glyph dot)" "$C_RESET" "$*"; }
+
+# Collapsed summary line for a finished step.
+ui_done() {
+ local label="$1" stat="${2:-}"
+ (( QUIET_LEVEL >= 2 )) && return
+ if [[ -n $stat ]]; then
+ printf ' %s%s%s %s %s\xe2\x80\x94%s %s%s%s\n' \
+ "$C_GREEN" "$(ui_glyph check)" "$C_RESET" "$label" \
+ "$C_DIM$C_GRAY" "$C_RESET" "$C_GRAY" "$stat" "$C_RESET"
+ else
+ printf ' %s%s%s %s\n' "$C_GREEN" "$(ui_glyph check)" "$C_RESET" "$label"
+ fi
+}
+
+ui_fail() {
+ local label="$1" rc="${2:-1}"
+ printf ' %s%s%s %s %s(rc=%s)%s\n' \
+ "$C_RED$C_BOLD" "$(ui_glyph cross)" "$C_RESET" "$label" "$C_DIM$C_GRAY" "$rc" "$C_RESET" >&2
+}
+
+# Live log monitor: follow $file, print spinner frame plus last tail line.
+# Replaces the old blind spinner.
+_MON_PID=0
+ui_monitor_start() {
+ (( QUIET_LEVEL >= 2 )) && return
+ (( ${UI_VERBOSE:-0} )) && { ui_sub "$1"; return; }
+ (( UI_TTY )) || { ui_sub "$1"; return; }
+ local label="$1" file="$2" hint="${3:-}"
+ (
+ local frames
+ if (( UI_UNICODE )); then
+ frames=($'\xe2\xa0\x8b' $'\xe2\xa0\x99' $'\xe2\xa0\xb9' $'\xe2\xa0\xb8' $'\xe2\xa0\xbc' $'\xe2\xa0\xb4' $'\xe2\xa0\xa6' $'\xe2\xa0\xa7' $'\xe2\xa0\x87' $'\xe2\xa0\x8f')
+ else
+ frames=('|' '/' '-' '\\')
+ fi
+ local i=0
+ trap 'exit 0' TERM
+ while :; do
+ local last="" w max
+ w=$(tput cols 2>/dev/null || echo 80)
+ max=$(( w - 10 - ${#label} - ${#hint} ))
+ (( max < 10 )) && max=10
+ if [[ -f $file ]]; then
+ last=$(tail -n 1 "$file" 2>/dev/null | tr -d '\r' | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')
+ last=${last:0:max}
+ fi
+ printf "\r\033[K %s%s%s %s%s %s%s%s" \
+ "$C_CYAN" "${frames[i]}" "$C_RESET" \
+ "$label" \
+ "$( [[ -n $hint ]] && printf ' %s(%s)%s' "$C_DIM$C_GRAY" "$hint" "$C_RESET" )" \
+ "$C_DIM$C_GRAY" "$last" "$C_RESET"
+ i=$(( (i+1) % ${#frames[@]} ))
+ sleep 0.12
+ done
+ ) &
+ _MON_PID=$!
+ disown "$_MON_PID" 2>/dev/null || true
+}
+
+ui_monitor_stop() {
+ if (( _MON_PID > 0 )) 2>/dev/null; then
+ kill "$_MON_PID" 2>/dev/null || true
+ wait "$_MON_PID" 2>/dev/null || true
+ _MON_PID=0
+ (( UI_TTY )) && printf '\r\033[K'
+ fi
+}
+
+# Back-compat aliases used elsewhere.
+ui_spinner_start() { ui_monitor_start "$1" /dev/null; }
+ui_spinner_stop() { ui_monitor_stop; }
+
+ui_print_logo() {
+ local logo="$1"
+ [[ -f $logo ]] || return 0
+ (( QUIET_LEVEL >= 1 )) && return
+ local w; w=$(ui_width)
+ (( w < 60 )) && return 0
+ printf '%s' "$C_GREEN$C_BOLD"
+ cat "$logo"
+ printf '%s\n' "$C_RESET"
+}
+
+# Pretty banner box used for the final summary.
+ui_box() {
+ local title="$1"; shift
+ local lines=("$@")
+ local tl tr bl br v h
+ tl=$(ui_glyph tl); tr=$(ui_glyph tr); bl=$(ui_glyph bl); br=$(ui_glyph br)
+ v=$(ui_glyph v); h=$(ui_glyph hline)
+ local maxlen=${#title}
+ local l
+ for l in "${lines[@]}"; do
+ local stripped; stripped=$(printf '%s' "$l" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')
+ (( ${#stripped} > maxlen )) && maxlen=${#stripped}
+ done
+ (( maxlen < 30 )) && maxlen=30
+ local bar; printf -v bar '%*s' "$((maxlen+2))" ''; bar=${bar// /$h}
+ printf '\n %s%s%s%s%s\n' "$C_CYAN" "$tl" "$bar" "$tr" "$C_RESET"
+ printf ' %s%s%s %s%-*s%s %s%s%s\n' "$C_CYAN" "$v" "$C_RESET" "$C_BOLD" "$maxlen" "$title" "$C_RESET" "$C_CYAN" "$v" "$C_RESET"
+ printf ' %s%s%s%s%s\n' "$C_CYAN" "$v" "$bar" "$v" "$C_RESET"
+ for l in "${lines[@]}"; do
+ local stripped; stripped=$(printf '%s' "$l" | sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g')
+ local pad=$(( maxlen - ${#stripped} ))
+ printf ' %s%s%s %s%*s %s%s%s\n' "$C_CYAN" "$v" "$C_RESET" "$l" "$pad" "" "$C_CYAN" "$v" "$C_RESET"
+ done
+ printf ' %s%s%s%s%s\n' "$C_CYAN" "$bl" "$bar" "$br" "$C_RESET"
+}
diff --git a/src/lib/update.sh b/src/lib/update.sh
new file mode 100644
index 0000000..190f361
--- /dev/null
+++ b/src/lib/update.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+# Self-update (git pull) and auto-update scheduling (systemd user timer or cron).
+
+self_update() {
+ local root="$1"
+ ui_title "Self-update"
+ if [[ ! -d "$root/.git" ]]; then
+ ui_err "Not a git checkout. Re-clone: git clone https://github.com/MasterCruelty/eMerger"
+ return 1
+ fi
+ if ! sys_has git; then
+ ui_err "git not installed"
+ return 1
+ fi
+ local branch before after
+ branch=$(git -C "$root" rev-parse --abbrev-ref HEAD)
+ ui_info "Branch: $branch"
+ if (( DRY_RUN )); then
+ ui_sub "[dry-run] git -C $root pull --ff-only"
+ return 0
+ fi
+ before=$(git -C "$root" rev-parse HEAD)
+ if ! git -C "$root" fetch --quiet; then
+ ui_err "git fetch failed"
+ return 1
+ fi
+ if ! git -C "$root" pull --ff-only --quiet; then
+ ui_err "git pull failed (non fast-forward?)"
+ return 1
+ fi
+ after=$(git -C "$root" rev-parse HEAD)
+ if [[ $before == "$after" ]]; then
+ ui_ok "Already up to date."
+ else
+ ui_ok "Updated ${before:0:7}..${after:0:7}"
+ git -C "$root" log --oneline "$before..$after" | sed "s/^/ $(ui_glyph dot) /"
+ fi
+}
+
+setup_cron() {
+ local root="$1"
+ ui_title "Enable auto-update"
+ if sys_has systemctl && [[ -d /run/systemd/system ]] || sys_has systemctl && systemctl --user show-environment >/dev/null 2>&1; then
+ local unit_dir="$HOME/.config/systemd/user"
+ mkdir -p "$unit_dir"
+ cat >"$unit_dir/emerger.service" <"$unit_dir/emerger.timer" </dev/null | grep -v "emerger.sh" || true; echo "$cronline" ) | crontab -
+ ui_ok "Weekly cronjob installed (Sunday 10:00)"
+ else
+ ui_err "Neither systemd --user nor crontab available."
+ return 1
+ fi
+}
diff --git a/src/lib/wizard.sh b/src/lib/wizard.sh
new file mode 100644
index 0000000..dbf0e06
--- /dev/null
+++ b/src/lib/wizard.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# First-run wizard: ask 3 questions, seed config.sh.
+
+wizard_maybe_run() {
+ local cfg="$EMERGER_CONFIG/config.sh"
+ local flag="$EMERGER_CONFIG/.wizard-done"
+ [[ -f $flag ]] && return 0
+ [[ ! -t 0 ]] && return 0
+ (( ARG_QUIET )) && return 0
+
+ ui_title "First-run setup"
+ ui_muted "Quick 3-question setup. Skip with Ctrl-C at any time."
+ mkdir -p "$EMERGER_CONFIG"
+
+ local a1 a2 a3
+ read -r -p " Include dev toolchains (rust/npm/pip...) by default? [y/N]: " a1 || a1=n
+ read -r -p " Show weather line? [y/N]: " a2 || a2=n
+ read -r -p " Install weekly auto-update timer? [y/N]: " a3 || a3=n
+
+ {
+ printf '# eMerger user config - generated by the wizard %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ printf '# Edit freely; it is sourced before argument parsing.\n\n'
+ [[ $a1 == [yY]* ]] && printf 'ARG_DEV=1\n'
+ [[ $a2 == [yY]* ]] && printf 'ARG_WEATHER=1\n'
+ } >"$cfg"
+
+ touch "$flag"
+ ui_ok "Wrote $cfg"
+ if [[ $a3 == [yY]* ]]; then
+ setup_cron "$EMERGER_ROOT" || true
+ fi
+}
diff --git a/src/logo/logo.txt b/src/logo/logo.txt
new file mode 100644
index 0000000..8092204
--- /dev/null
+++ b/src/logo/logo.txt
@@ -0,0 +1,6 @@
+โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโ โโโโโโโโโโโโโโโ
+โโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
+โโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโ โโโโโโโโ
+โโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโ โโโโโโโโ
+โโโโโโโโโโโ โโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโ
+โโโโโโโโโโโ โโโโโโโโโโโโโโ โโโ โโโโโโโ โโโโโโโโโโโ โโโ
diff --git a/src/package/Linux/arch.sh b/src/package/Linux/arch.sh
deleted file mode 100755
index a98b49b..0000000
--- a/src/package/Linux/arch.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="pacman"
-
-puts BLUE "Using $PKG $ARCH"
-
-puts RED "update: starting"
-try $PWR $PKG -Syy
-puts GREEN "update: completed"
-
-puts RED "upgrade: starting"
-try $PWR $PKG -Syu
-puts GREEN "upgrade: completed"
-
-puts RED "clean pacman caches: starting"
-try $PWR paccache -r
-puts GREEN "clean pacman caches: starting"
-
-puts RED "update AUR packages: starting"
-try yay -Syu
-puts GREEN "update AUR packages: starting"
diff --git a/src/package/Linux/debian.sh b/src/package/Linux/debian.sh
deleted file mode 100755
index dc26403..0000000
--- a/src/package/Linux/debian.sh
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="apt-get"
-
-
-if [[ $(command -v apt) ]]; then
- PKG="apt"
-
- #These lines works on some systems only, for example I receive an error on a raspberry.
- #puts RED "configuration: starting"
- #try $PWR $PKG --configure -a
- #puts GREEN "configuration: completed"
- #puts RED "Error on --configure option"
-
- puts BLUE "Using $PKG $DEBIAN"
-
- puts RED "fix broken: starting"
- try $PWR $PKG --fix-broken install
- puts GREEN "fix broken: completed"
-
- puts RED "update: starting"
- try $PWR $PKG update
- puts GREEN "update: completed"
-
- puts RED "full-upgrade: starting"
- try $PWR $PKG full-upgrade
- puts GREEN "full-upgrade: completed"
-
- puts RED "autoremove: starting"
- try $PWR $PKG autoremove
- puts GREEN "autoremove: completed"
-
- puts RED "autoclean: starting"
- try $PWR $PKG autoclean
- puts GREEN "autoclean: completed"
-
- puts RED "clean: starting"
- try $PWR $PKG clean
- puts GREEN "clean: completed"
-else
- puts BLUE "Using $PKG $DEBIAN"
-
- puts RED "update: starting"
- try $PWR $PKG update
- puts GREEN "update: completed"
-
- puts RED "dist-upgrade: starting"
- try $PWR $PKG dist-upgrade
- puts GREEN "dist-upgrade: completed"
-
- puts RED "autoremove: starting"
- try $PWR $PKG autoremove
- puts GREEN "autoremove: completed"
-
- puts RED "autoclean: starting"
- try $PWR $PKG autoclean
- puts GREEN "autoclean: completed"
-
- puts RED "clean: starting"
- try $PWR $PKG clean
- puts GREEN "clean: completed"
-fi
diff --git a/src/package/Linux/flatpak.sh b/src/package/Linux/flatpak.sh
deleted file mode 100755
index d844147..0000000
--- a/src/package/Linux/flatpak.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="flatpak"
-
-puts BLUE "Using $PKG $FLATPAK"
-
-puts RED "update: starting"
-try $PWR $PKG update
-puts GREEN "update: completed"
-
-puts RED "repair: starting"
-try $PWR $PKG repair
-puts GREEN "repair: completed"
-
-puts RED "uninstall unused extensions: starting"
-try $PWR $PKG uninstall --unused
-puts GREEN "uninstall unused extensions: completed"
diff --git a/src/package/Linux/gentoo.sh b/src/package/Linux/gentoo.sh
deleted file mode 100755
index 17c6354..0000000
--- a/src/package/Linux/gentoo.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="emerge"
-
-puts BLUE "Using $PKG $GENTOO"
-
-puts RED "syncing: starting"
-try $PWR $PKG --sync
-puts GREEN "syncing: completed"
-
-puts RED "update: starting"
-try $PWR $PKG --update --deep --newuse --with-bdeps y @world --ask
-puts GREEN "update: completed"
-
-puts RED "deepclean: starting"
-try $PWR $PKG --depclean --ask
-revdep-rebuild
-puts GREEN "deepclean: completed"
diff --git a/src/package/Linux/nix.sh b/src/package/Linux/nix.sh
deleted file mode 100755
index 18b268d..0000000
--- a/src/package/Linux/nix.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="nixos-rebuild switch"
-
-puts BLUE "Using $PKG $NIX"
-
-puts RED "upgrade: starting"
-try $PWR $PKG --upgrade
-puts GREEN "upgrade: completed"
-
-puts RED "repair: starting"
-try $PWR $PKG --repair
-puts GREEN "repair: completed"
diff --git a/src/package/Linux/opensuse.sh b/src/package/Linux/opensuse.sh
deleted file mode 100755
index a21f857..0000000
--- a/src/package/Linux/opensuse.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="zypper"
-
-puts BLUE "Using $PKG $OPENSUSE"
-
-puts RED "refresh: starting"
-try $PWR $PKG refresh
-puts GREEN "refresh: completed"
-
-puts RED "update: starting"
-try $PWR $PKG up 2>/dev/null || $PWR $PKG dup
-puts GREEN "update: completed"
-
-puts RED "remove dependencies: starting"
-try $PWR $PKG rm chromium --clean-deps
-puts GREEN "remove dependencies: completed"
-
-puts RED "list unneeded packages: starting"
-try $PWR $PKG packages --unneeded
-puts GREEN "list unneeded packages: completed"
diff --git a/src/package/Linux/rpm.sh b/src/package/Linux/rpm.sh
deleted file mode 100755
index 4d9dddc..0000000
--- a/src/package/Linux/rpm.sh
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="rpm"
-
-if [[ $(command -v yum) ]]; then
- PKG="yum"
-
- if [[ $(command -v dnf) ]]; then
- PKG="dnf"
- fi
-
- puts BLUE "Using $PKG $RPM"
-
- puts RED "update: starting"
- try $PWR $PKG update
- puts GREEN "update: completed"
-
- puts RED "upgrade: starting"
- try $PWR $PKG upgrade
- puts GREEN "upgrade: completed"
-
- puts RED "autoremove: starting"
- try $PWR $PKG autoremove
- puts GREEN "autoremove: completed"
-
- puts RED "clean all: starting"
- try $PWR $PKG clean all
- puts GREEN "clean all: completed"
-else
- puts BLUE "\nUsing $PKG $RPM"
-
- puts RED "freshen: starting"
- try $PWR $PKG -l | xargs -I{} $PWR $PKG -F {}
- puts GREEN "freshen: completed"
-fi
diff --git a/src/package/Linux/snap.sh b/src/package/Linux/snap.sh
deleted file mode 100755
index 07ddf47..0000000
--- a/src/package/Linux/snap.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="snap"
-
-puts BLUE "Using $PKG $SNAP"
-
-puts RED "refresh: starting"
-try $PWR $PKG refresh
-puts GREEN "refresh: completed"
diff --git a/src/package/Linux/termux.sh b/src/package/Linux/termux.sh
deleted file mode 100755
index 801f3ef..0000000
--- a/src/package/Linux/termux.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/bash
-
-SRC=$(dirname "$(readlink -f "$0")")
-source $SRC/utils/global.sh
-
-PWR=$(source $SRC/utils/check_pwr.sh)
-PKG="pkg"
-
-puts BLUE "Using $PKG $TERMUX"
-
-puts RED "update: starting"
-try $PWR $PKG update
-puts GREEN "update: completed"
-
-puts RED "upgrade: starting"
-try $PWR $PKG upgrade
-puts GREEN "upgrade: completed"
-
-puts RED "autoclean: starting"
-try $PWR $PKG autoclean
-puts GREEN "autoclean: completed"
diff --git a/src/package/MacOS/MacOSUpdate.sh b/src/package/MacOS/MacOSUpdate.sh
deleted file mode 100644
index 3862175..0000000
--- a/src/package/MacOS/MacOSUpdate.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/bash
-#Fetch MacOS version
-osVersion=$(sw_vers -productVersion)
-
-installerPath=""
-#Get major and minor version
-majorVersion=$(echo $osVersion | cut -d "." -f 1)
-minorVersion=$(echo $osVersion | cut -d "." -f 2)
-
-#Check which version is installed
-if [ $majorVersion == "12" ];then
- installerPath="install macOS Monterey.app"
-elif [ $majorVersion == "11" ];then
- installerPath="Install macOS Big Sur.app"
-elif [ $minorVersion == "15"* ];then
- installerPath="Install macOS Catalina.app"
-elif
- echo "Unsupported MacOS version."
- exit 1
-fi
-
-#get full path
-fullPath="/Applications/$installerPath/Contents/Resources/startosinstall"
-
-#command which updates system
-softwareupdate --fetch-full-installer โfull-installer-version $osVersion
-echo "Inserire la password:"
-#get privileges
-sudo "$fullPath" --agreetolicense --forcequitapps --nointeraction --user "$USER"
diff --git a/src/package/Windows/privileges.ps1 b/src/package/Windows/privileges.ps1
deleted file mode 100644
index 15b4fb3..0000000
--- a/src/package/Windows/privileges.ps1
+++ /dev/null
@@ -1,3 +0,0 @@
-# Start Powershell as administrator
-Start-Process powershell -Verb RunAs -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"winUpdate.ps1`""
-
diff --git a/src/package/Windows/winUpdate.ps1 b/src/package/Windows/winUpdate.ps1
deleted file mode 100644
index f5cb2b6..0000000
--- a/src/package/Windows/winUpdate.ps1
+++ /dev/null
@@ -1,48 +0,0 @@
-# This script is focused on update Windows OS
-
-# Install PSWindowsUpdate if not installed
-if (!(Get-Module -Name PSWindowsUpdate -ListAvailable)) {
- Write-Host "Installing PSWindowsUpdate..."
- Install-Module -Name PSWindowsUpdate -Force -Scope CurrentUser
-}
-
-# Windows Update
-Write-Host "Updating Windows..."
-Write-Host "At the end your system will be rebooted..."
-Get-WUInstall -AcceptAll -AutoReboot
-
-
-####### Check installation and update of Chocolatey #######
- # If you wanna install Chocolatey, you need this command
- # Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
-if (!(Get-Command choco -ErrorAction SilentlyContinue)) {
- Write-Host "Chocolatey not found. Skipping..."
-} else {
- Write-Host "Chocolatey is installed..."
- Write-Host "Updating Chocolatey packages..."
- choco upgrade all -y
-}
-
-######## Check installation and update Nuget #######
- # If you wanna install Nugetm you need these commands.
- # Invoke-Expression "& { $(irm https://dist.nuget.org/win-x86-commandline/latest/nuget.exe) | Out-File -FilePath $env:temp\nuget.exe }"
- # Move-Item -Path "$env:temp\nuget.exe" -Destination "$env:ProgramFiles\NuGet" -Force
-if (!(Get-Command nuget -ErrorAction SilentlyContinue)) {
- Write-Host "NuGet not found. Skipping..."
-} else {
- Write-Host "NuGet is installed..."
- Write-Host "Updating Nuget packages..."
- Update-Package -ProjectName YourProjectName -Reinstall
-}
-
-####### Check installation and update scoop #######
- # If you wanna install Scoop, you need this command.
- # Set-ExecutionPolicy RemoteSigned -scope Process; iex (new-object net.webclient).downloadstring('https://get.scoop.sh')
-if (!(Test-Path $env:USERPROFILE\scoop)) {
- Write-Host "Scoop not found. Skipping..."
-} else {
- Write-Host "Scoop is installed..."
- scoop update
-}
-
-Write-Host "Update completed."
diff --git a/src/pslib/Args.ps1 b/src/pslib/Args.ps1
new file mode 100644
index 0000000..8fb8de7
--- /dev/null
+++ b/src/pslib/Args.ps1
@@ -0,0 +1,107 @@
+# Argument parser. Returns a PSCustomObject with booleans / strings.
+
+function Parse-Args {
+ param([string[]]$Argv)
+ $o = [pscustomobject]@{
+ Help=$false; Version=$false
+ DryRun=$false; Verbose=$false; Yes=$false
+ Dev=$false; Security=$false; Firmware=$false
+ NoLogo=$false; NoInfo=$false; NoCache=$false; NoTrash=$false
+ Errors=$false; History=$false; Doctor=$false
+ SelfUpdate=$false; AutoUpdate=$false; RebuildCache=$false
+ Snapshot=$false; Reboot=$false
+ Profile=''; Changelog=''; Report=''
+ ListProfiles=$false; NoEmoji=$false; Interactive=$false
+ Parallel=$false; Changed=$false; Resume=$false
+ Json=$false; RebootExit=$false; DownloadOnly=$false
+ Only=''; Except=''; Metrics=''
+ }
+ if (-not $Argv) { return $o }
+ # Expand short-flag bundles: "-nv" -> "-n -v".
+ $allowed = 'hVnvqyiw'.ToCharArray()
+ $keep = @('-up','-au','-err','-rc','-nl','-ni','-nc','-nt','-qq','-qqq','-xyzzy',
+ '-h','-V','-n','-v','-q','-y','-i','-w')
+ $expanded = New-Object System.Collections.Generic.List[string]
+ foreach ($a in $Argv) {
+ $s = [string]$a
+ if ($keep -contains $s -or $s.StartsWith('--')) { [void]$expanded.Add($s); continue }
+ if ($s -match '^-[A-Za-z]{2,}$') {
+ $letters = $s.Substring(1).ToCharArray()
+ $all = $true
+ foreach ($c in $letters) { if ($allowed -notcontains $c) { $all = $false; break } }
+ if ($all) {
+ foreach ($c in $letters) { [void]$expanded.Add("-$c") }
+ continue
+ }
+ }
+ [void]$expanded.Add($s)
+ }
+ $Argv = $expanded.ToArray()
+ $i = 0
+ while ($i -lt $Argv.Count) {
+ $a = [string]$Argv[$i]
+ switch -Regex -CaseSensitive ($a) {
+ '^(-h|--help|-help)$' { $o.Help = $true }
+ '^(-V|--version)$' { $o.Version = $true }
+ '^(-n|--dry-run)$' { $o.DryRun = $true }
+ '^(-v|--verbose)$' { $o.Verbose = $true; $script:UI_VERBOSE = $true }
+ '^(-q|--quiet)$' { $script:QUIET_LEVEL++ }
+ '^-qq$' { $script:QUIET_LEVEL = 2 }
+ '^-qqq$' { $script:QUIET_LEVEL = 3 }
+ '^(-y|--yes)$' { $o.Yes = $true }
+ '^--dev$' { $o.Dev = $true }
+ '^--security$' { $o.Security = $true }
+ '^--firmware$' { $o.Firmware = $true }
+ '^(-nl|--no-logo)$' { $o.NoLogo = $true }
+ '^(-ni|--no-info)$' { $o.NoInfo = $true }
+ '^(-nc|--no-cache)$' { $o.NoCache = $true }
+ '^(-nt|--no-trash)$' { $o.NoTrash = $true }
+ '^(-err|--errors)$' { $o.Errors = $true }
+ '^--history$' { $o.History = $true }
+ '^--doctor$' { $o.Doctor = $true }
+ '^(-up|--self-update)$' { $o.SelfUpdate = $true }
+ '^(-au|--auto-update)$' { $o.AutoUpdate = $true }
+ '^(-rc|--rebuild-cache)$' { $o.RebuildCache = $true }
+ '^--snapshot$' { $o.Snapshot = $true }
+ '^--reboot$' { $o.Reboot = $true }
+ '^--list-profiles$' { $o.ListProfiles = $true }
+ '^--no-emoji$' { $o.NoEmoji = $true; $script:UI_UNICODE = $false }
+ '^(-i|--interactive)$' { $o.Interactive = $true }
+ '^--parallel$' { $o.Parallel = $true }
+ '^--changed$' { $o.Changed = $true }
+ '^--resume$' { $o.Resume = $true }
+ '^--json$' { $o.Json = $true }
+ '^--reboot-exit$' { $o.RebootExit = $true }
+ '^(--download-only|--offline)$' { $o.DownloadOnly = $true }
+ '^--only$' { $i++; $o.Only = [string]$Argv[$i] }
+ '^--only=' { $o.Only = $a -replace '^--only=','' }
+ '^--except$' { $i++; $o.Except = [string]$Argv[$i] }
+ '^--except=' { $o.Except = $a -replace '^--except=','' }
+ '^--metrics$' { $i++; $o.Metrics = [string]$Argv[$i] }
+ '^--metrics=' { $o.Metrics = $a -replace '^--metrics=','' }
+ '^--profile$' { $i++; $o.Profile = [string]$Argv[$i] }
+ '^--profile=' { $o.Profile = $a -replace '^--profile=','' }
+ '^--changelog$' { $i++; $o.Changelog = [string]$Argv[$i] }
+ '^--changelog=' { $o.Changelog = $a -replace '^--changelog=','' }
+ '^--report$' { $i++; $o.Report = [string]$Argv[$i] }
+ '^--report=' { $o.Report = $a -replace '^--report=','' }
+ default {
+ [Console]::Error.WriteLine("Unknown argument: $a (try 'up --help')")
+ exit 2
+ }
+ }
+ $i++
+ }
+ return $o
+}
+
+function Args-Prescan-Profile {
+ param([string[]]$Argv)
+ if (-not $Argv) { return '' }
+ for ($i = 0; $i -lt $Argv.Count; $i++) {
+ $a = [string]$Argv[$i]
+ if ($a -eq '--profile' -and ($i + 1) -lt $Argv.Count) { return [string]$Argv[$i+1] }
+ if ($a -match '^--profile=') { return ($a -replace '^--profile=','') }
+ }
+ return ''
+}
diff --git a/src/pslib/Clean.ps1 b/src/pslib/Clean.ps1
new file mode 100644
index 0000000..594caa5
--- /dev/null
+++ b/src/pslib/Clean.ps1
@@ -0,0 +1,54 @@
+# Clean %TEMP% folders and the Recycle Bin.
+
+function _Clean-Confirm {
+ param([string]$Prompt)
+ if ($script:ArgsGlobal.Yes) { return $true }
+ $a = Read-Host " $Prompt [y/N]"
+ return $a -match '^[Yy]'
+}
+
+function _Folder-Size-MB {
+ param([string]$Path)
+ if (-not (Test-Path $Path)) { return 0 }
+ try {
+ $sum = (Get-ChildItem $Path -Recurse -Force -ErrorAction SilentlyContinue |
+ Measure-Object Length -Sum).Sum
+ if (-not $sum) { return 0 }
+ return [math]::Round($sum / 1MB, 1)
+ } catch { return 0 }
+}
+
+function Clean-Temp {
+ UI-Title 'Temp folders'
+ $paths = @($env:TEMP, (Join-Path $env:LOCALAPPDATA 'Temp'), (Join-Path $env:SystemRoot 'Temp')) |
+ Where-Object { $_ } | Select-Object -Unique
+ $before = 0
+ foreach ($p in $paths) { $before += (_Folder-Size-MB $p) }
+ UI-Muted ("Paths: {0} Size: {1} MB" -f ($paths -join ', '), $before)
+
+ if ($script:ArgsGlobal.DryRun) { UI-Sub '[dry-run] would clean temp folders'; return }
+ if (-not (_Clean-Confirm 'Clean temp folders?')) { UI-Muted 'skipped'; return }
+
+ foreach ($p in $paths) {
+ if (-not (Test-Path $p)) { continue }
+ Get-ChildItem $p -Force -ErrorAction SilentlyContinue | ForEach-Object {
+ try { Remove-Item $_.FullName -Recurse -Force -ErrorAction Stop } catch {}
+ }
+ }
+ $after = 0
+ foreach ($p in $paths) { $after += (_Folder-Size-MB $p) }
+ $script:SUMMARY_FREED_MB += [math]::Max(0, $before - $after)
+ UI-Ok ("temp cleaned ({0} MB)" -f [math]::Max(0, $before - $after))
+}
+
+function Clean-RecycleBin {
+ UI-Title 'Recycle bin'
+ if ($script:ArgsGlobal.DryRun) { UI-Sub '[dry-run] would empty recycle bin'; return }
+ if (-not (_Clean-Confirm 'Empty recycle bin?')) { UI-Muted 'skipped'; return }
+ try {
+ Clear-RecycleBin -Force -ErrorAction Stop
+ UI-Ok 'recycle bin emptied'
+ } catch {
+ UI-Warn "could not empty recycle bin: $($_.Exception.Message)"
+ }
+}
diff --git a/src/pslib/Doctor.ps1 b/src/pslib/Doctor.ps1
new file mode 100644
index 0000000..34f204f
--- /dev/null
+++ b/src/pslib/Doctor.ps1
@@ -0,0 +1,45 @@
+# Environment health check.
+
+function Doctor-Run {
+ UI-Title 'eMerger doctor (Windows)'
+ $issues = 0
+
+ UI-Ok "PowerShell $($PSVersionTable.PSVersion)"
+
+ if (Sys-IsAdmin) { UI-Ok 'running as administrator' }
+ else { UI-Info 'not admin (elevation prompted when needed)' }
+
+ $free = Sys-Disk-Free-GB 'C'
+ if ($free -lt 2) { UI-Warn "low disk space on C: ($free GB)"; $issues++ }
+ else { UI-Ok "disk: $free GB free on C:" }
+
+ try {
+ if (Test-Connection -ComputerName 'github.com' -Count 1 -Quiet -ErrorAction Stop) {
+ UI-Ok 'network: github reachable'
+ } else { UI-Warn 'network: github unreachable'; $issues++ }
+ } catch { UI-Warn 'network: check failed'; $issues++ }
+
+ $ep = Get-ExecutionPolicy -Scope CurrentUser
+ if ($ep -eq 'Restricted') { UI-Warn "ExecutionPolicy=$ep (run setup.ps1 to fix)"; $issues++ }
+ else { UI-Ok "ExecutionPolicy: $ep" }
+
+ foreach ($m in $script:PKG_MANAGERS) {
+ if (Pkg-Detect $m) { UI-Ok "$m: detected" }
+ }
+
+ if (Reboot-Pending) { UI-Warn 'reboot pending from a previous operation' }
+
+ if ($script:ArgsGlobal.Dev) {
+ foreach ($m in $script:PKG_DEV) {
+ if (Pkg-Detect $m) { UI-Ok "$m (dev): detected" }
+ }
+ }
+
+ UI-Hr
+ if ($issues -eq 0) {
+ UI-Ok 'all clear'
+ return 0
+ }
+ UI-Warn "$issues issue(s) found"
+ return 1
+}
diff --git a/src/pslib/Help.txt b/src/pslib/Help.txt
new file mode 100644
index 0000000..c2fe0cd
--- /dev/null
+++ b/src/pslib/Help.txt
@@ -0,0 +1,70 @@
+eMerger (Windows) - one-command system updater
+
+USAGE
+ up [options]
+
+GENERAL
+ -h, --help Show this help
+ -V, --version Show version
+ --doctor Run environment health check
+
+EXECUTION
+ -n, --dry-run Show what would be done, do nothing
+ -v, --verbose Stream pkg-manager output live
+ -q / -qq / -qqq Quieter (titles / summary only / exit code only)
+ -y, --yes Assume "yes" on prompts
+ --json Machine-readable run summary on stdout
+ Short single-letter flags bundle: -nv == -n -v
+
+SELECTION
+ --security Prefer security-only where supported
+ --dev Include dev toolchains (rustup, cargo, npm, pnpm, pip, gem)
+ --profile NAME Load a profile (share\profiles\.ps1)
+ --list-profiles List available profiles
+ --no-emoji ASCII glyphs only
+
+DISPLAY
+ -nl, --no-logo Hide logo
+ -ni, --no-info Hide system info line
+ -nc, --no-cache Skip %TEMP% cleaning
+ -nt, --no-trash Skip recycle bin
+
+INSPECTION
+ --history Last runs
+ -err, --errors Tail of logged errors
+ --reboot Reboot if the system requires it
+ --reboot-exit Exit 4 instead of 0 when a reboot is required
+ --download-only Download packages, do not install (where supported)
+ --offline Alias for --download-only
+ --only LIST Comma-separated managers to keep
+ --except LIST Comma-separated managers to skip
+ --metrics FILE Export last run as Prometheus textfile
+
+MAINTENANCE
+ -up, --self-update Update eMerger via git (fast-forward)
+ -au, --auto-update Register weekly Task Scheduler entry
+ -rc, --rebuild-cache Clear detection cache (no-op stub)
+
+EXAMPLES
+ up # normal run (elevates if needed)
+ up -n # preview
+ up --security -y # unattended security
+ up --dev # include rustup/npm/pip/gem
+ up --profile work # named profile
+
+CONFIG
+ %APPDATA%\emerger\config.ps1 Dot-sourced before argument parsing
+ %APPDATA%\emerger\profiles.d\*.ps1 User profiles
+ %APPDATA%\emerger\hooks\pre.d\*.ps1 Pre-update hooks
+ %APPDATA%\emerger\hooks\post.d\*.ps1 Post-update hooks
+
+STATE
+ %LOCALAPPDATA%\emerger\state\emerger.log Log
+ %LOCALAPPDATA%\emerger\state\history.jsonl Run history
+
+EXIT CODES
+ 0 success
+ 1 runtime failure
+ 2 argument parsing error
+ 3 one or more package managers failed
+ 4 reboot required (only with --reboot-exit)
diff --git a/src/pslib/Hooks.ps1 b/src/pslib/Hooks.ps1
new file mode 100644
index 0000000..1faf9dc
--- /dev/null
+++ b/src/pslib/Hooks.ps1
@@ -0,0 +1,18 @@
+# User hook runner: *.ps1 scripts under hooks/{pre,post}.d/
+
+function Hooks-Run {
+ param([string]$Phase)
+ $dir = Join-Path $script:EMERGER_CONFIG "hooks\$Phase.d"
+ if (-not (Test-Path $dir)) { return }
+ $scripts = @(Get-ChildItem $dir -Filter '*.ps1' -ErrorAction SilentlyContinue)
+ if ($scripts.Count -eq 0) { return }
+ UI-Title "Hooks ($Phase)"
+ foreach ($s in $scripts) {
+ UI-Sub $s.Name
+ if ($script:ArgsGlobal.DryRun) { continue }
+ try { & $s.FullName } catch {
+ UI-Warn "hook $($s.Name) failed: $($_.Exception.Message)"
+ Log-Warn "hook $Phase/$($s.Name) failed"
+ }
+ }
+}
diff --git a/src/pslib/Log.ps1 b/src/pslib/Log.ps1
new file mode 100644
index 0000000..19d115d
--- /dev/null
+++ b/src/pslib/Log.ps1
@@ -0,0 +1,25 @@
+# Structured logging to $EMERGER_LOG.
+
+function Log-Init {
+ $script:EMERGER_LOG = Join-Path $script:EMERGER_STATE 'emerger.log'
+ if (-not (Test-Path $script:EMERGER_STATE)) {
+ New-Item -ItemType Directory -Path $script:EMERGER_STATE -Force | Out-Null
+ }
+ if (-not (Test-Path $script:EMERGER_LOG)) {
+ New-Item -ItemType File -Path $script:EMERGER_LOG -Force | Out-Null
+ }
+ # Rotate at 2000 lines.
+ $count = (Get-Content $script:EMERGER_LOG -ErrorAction SilentlyContinue | Measure-Object).Count
+ if ($count -gt 2000) {
+ Get-Content $script:EMERGER_LOG -Tail 2000 | Set-Content $script:EMERGER_LOG
+ }
+}
+
+function Log-Write {
+ param([string]$Level, [string]$Msg)
+ $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ try { Add-Content -Path $script:EMERGER_LOG -Value "$ts|$Level|$Msg" -ErrorAction Stop } catch {}
+}
+function Log-Info { param([string]$M) Log-Write 'INFO' $M }
+function Log-Warn { param([string]$M) Log-Write 'WARN' $M }
+function Log-Error { param([string]$M) Log-Write 'ERROR' $M }
diff --git a/src/pslib/Notify.ps1 b/src/pslib/Notify.ps1
new file mode 100644
index 0000000..7fdbd31
--- /dev/null
+++ b/src/pslib/Notify.ps1
@@ -0,0 +1,13 @@
+# Best-effort desktop notification. Uses BurntToast if installed, otherwise no-op.
+
+function Notify-Send-Result {
+ if (-not (Get-Module -ListAvailable -Name BurntToast -ErrorAction SilentlyContinue)) { return }
+ try {
+ Import-Module BurntToast -ErrorAction Stop
+ $errors = @($script:Summary | Where-Object { $_.Result -eq 'fail' }).Count
+ $total = @($script:Summary).Count
+ $msg = if ($errors -gt 0) { "Finished with $errors error(s)." }
+ else { "$total manager(s) updated." }
+ New-BurntToastNotification -Text 'eMerger', $msg -ErrorAction SilentlyContinue
+ } catch {}
+}
diff --git a/src/pslib/Packages.ps1 b/src/pslib/Packages.ps1
new file mode 100644
index 0000000..2a9ff91
--- /dev/null
+++ b/src/pslib/Packages.ps1
@@ -0,0 +1,148 @@
+# Windows package manager registry and dispatcher.
+
+$script:PKG_MANAGERS = @(
+ 'winget','scoop','choco','wsl','PSWindowsUpdate'
+)
+$script:PKG_DEV = @('rustup','cargo','npm','pnpm','pip','gem')
+
+function Pkg-Need-Admin {
+ param([string]$M)
+ return @('choco','PSWindowsUpdate','wsl') -contains $M
+}
+
+function Pkg-Detect {
+ param([string]$M)
+ switch ($M) {
+ 'winget' { return (Sys-Has winget) }
+ 'scoop' { return (Sys-Has scoop) }
+ 'choco' { return (Sys-Has choco) }
+ 'wsl' { return (Sys-Has wsl) }
+ 'PSWindowsUpdate' { return [bool](Get-Module -ListAvailable PSWindowsUpdate -ErrorAction SilentlyContinue) }
+ 'rustup' { return (Sys-Has rustup) }
+ 'cargo' { return (Sys-Has cargo) -and (Sys-Has cargo-install-update) }
+ 'npm' { return (Sys-Has npm) }
+ 'pnpm' { return (Sys-Has pnpm) }
+ 'pip' { return (Sys-Has pip) }
+ 'gem' { return (Sys-Has gem) }
+ default { return $false }
+ }
+}
+
+function Pkg-Detect-All {
+ param([switch]$IncludeDev)
+ $out = @()
+ foreach ($m in $script:PKG_MANAGERS) { if (Pkg-Detect $m) { $out += $m } }
+ if ($IncludeDev) { foreach ($m in $script:PKG_DEV) { if (Pkg-Detect $m) { $out += $m } } }
+ return ,$out
+}
+
+function Pkg-Need-Admin-Any {
+ param([switch]$IncludeDev)
+ $detected = Pkg-Detect-All -IncludeDev:$IncludeDev
+ foreach ($m in $detected) { if (Pkg-Need-Admin $m) { return $true } }
+ return $false
+}
+
+function Pkg-Icon {
+ param([string]$M)
+ if (-not $script:UI_UNICODE) { return '*' }
+ switch ($M) {
+ 'winget' { '๐ฆ' }
+ 'scoop' { '๐ฅ' }
+ 'choco' { '๐ซ' }
+ 'wsl' { '๐ง' }
+ 'PSWindowsUpdate' { '๐ช' }
+ 'rustup' { '๐ฆ' }
+ 'cargo' { '๐ฆ' }
+ 'npm' { '๐' }
+ 'pnpm' { '๐' }
+ 'pip' { '๐' }
+ 'gem' { '๐' }
+ default { '*' }
+ }
+}
+
+function Run-Cmd {
+ param([string]$Label, [scriptblock]$Block)
+ if ($script:ArgsGlobal.DryRun) {
+ UI-Sub "[dry-run] $Label"
+ return $true
+ }
+ if ($script:UI_VERBOSE) {
+ UI-Sub $Label
+ try { & $Block } catch { UI-Fail $Label 1; Log-Error "$Label : $_"; return $false }
+ if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { UI-Fail $Label $LASTEXITCODE; Log-Error "$Label rc=$LASTEXITCODE"; return $false }
+ UI-Done $Label
+ return $true
+ }
+ $tmp = [IO.Path]::GetTempFileName()
+ try {
+ & $Block *>$tmp
+ $rc = $LASTEXITCODE
+ if (-not $rc) { $rc = 0 }
+ } catch {
+ $rc = 1
+ $_ | Out-File $tmp -Append
+ }
+ if ($rc -ne 0) {
+ UI-Fail $Label $rc
+ Log-Error "$Label rc=$rc"
+ Get-Content $tmp -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object {
+ [Console]::Error.WriteLine(" $_")
+ }
+ Remove-Item $tmp -Force -ErrorAction SilentlyContinue
+ return $false
+ }
+ UI-Done $Label
+ Remove-Item $tmp -Force -ErrorAction SilentlyContinue
+ return $true
+}
+
+function Pkg-Run {
+ param([string]$M)
+ $script:PKG_CURRENT = $M
+ $icon = Pkg-Icon $M
+ UI-Title "$icon $M"
+ Log-Info "starting $M"
+ $ok = $true
+ switch ($M) {
+ 'winget' {
+ $ok = (Run-Cmd 'winget source update' { winget source update --disable-interactivity *>$null; $global:LASTEXITCODE = 0 }) -and $ok
+ $ok = (Run-Cmd 'winget upgrade --all' {
+ winget upgrade --all --include-unknown --accept-package-agreements --accept-source-agreements --silent --disable-interactivity
+ if (-not $LASTEXITCODE) { $global:LASTEXITCODE = 0 }
+ }) -and $ok
+ }
+ 'scoop' {
+ $ok = (Run-Cmd 'scoop update' { scoop update }) -and $ok
+ $ok = (Run-Cmd 'scoop update *' { scoop update * }) -and $ok
+ $ok = (Run-Cmd 'scoop cleanup *' { scoop cleanup * }) -and $ok
+ $ok = (Run-Cmd 'scoop cache rm *' { scoop cache rm * }) -and $ok
+ }
+ 'choco' {
+ $ok = (Run-Cmd 'choco upgrade all' { choco upgrade all -y --limit-output }) -and $ok
+ }
+ 'wsl' {
+ $ok = (Run-Cmd 'wsl --update' { wsl --update }) -and $ok
+ }
+ 'PSWindowsUpdate' {
+ $ok = (Run-Cmd 'Windows Update' {
+ Import-Module PSWindowsUpdate -ErrorAction Stop
+ Get-WindowsUpdate -AcceptAll -Install -IgnoreReboot -Confirm:$false
+ }) -and $ok
+ }
+ 'rustup' { $ok = (Run-Cmd 'rustup update' { rustup update }) -and $ok }
+ 'cargo' { $ok = (Run-Cmd 'cargo install-update -a' { cargo install-update -a }) -and $ok }
+ 'npm' { $ok = (Run-Cmd 'npm -g update' { npm update -g }) -and $ok }
+ 'pnpm' { $ok = (Run-Cmd 'pnpm -g update' { pnpm -g update }) -and $ok }
+ 'pip' { $ok = (Run-Cmd 'pip upgrade outdated' {
+ pip list --outdated --format=json 2>$null |
+ ConvertFrom-Json |
+ ForEach-Object { pip install --user -U $_.name 2>$null }
+ }) -and $ok }
+ 'gem' { $ok = (Run-Cmd 'gem update' { gem update }) -and $ok }
+ }
+ $script:PKG_CURRENT = ''
+ if ($ok) { Log-Info "$M ok" } else { Log-Error "$M failed" }
+ return $ok
+}
diff --git a/src/pslib/Profiles.ps1 b/src/pslib/Profiles.ps1
new file mode 100644
index 0000000..368dba0
--- /dev/null
+++ b/src/pslib/Profiles.ps1
@@ -0,0 +1,45 @@
+# Profiles: named *.ps1 files that preset $script:ArgsGlobal defaults.
+# Search:
+# 1) $EMERGER_CONFIG\profiles.d\.ps1 (user)
+# 2) $EMERGER_ROOT\share\profiles\.ps1 (shipped)
+
+function Load-Profile {
+ param([string]$Name)
+ if (-not $Name) { return }
+ if ($Name -notmatch '^[A-Za-z0-9._-]+$') {
+ [Console]::Error.WriteLine("Invalid profile name: '$Name' (allowed: letters, digits, dot, dash, underscore)")
+ exit 2
+ }
+ $candidates = @(
+ (Join-Path $script:EMERGER_CONFIG "profiles.d\$Name.ps1"),
+ (Join-Path $script:EMERGER_ROOT "share\profiles\$Name.ps1")
+ )
+ foreach ($f in $candidates) {
+ if (Test-Path -LiteralPath $f) {
+ . $f
+ Log-Info "profile loaded: $Name ($f)"
+ return
+ }
+ }
+ [Console]::Error.WriteLine("Profile '$Name' not found. Looked in:")
+ $candidates | ForEach-Object { [Console]::Error.WriteLine(" $_") }
+ exit 2
+}
+
+function List-Profiles {
+ UI-Title 'Available profiles'
+ $seen = @{}
+ foreach ($d in @((Join-Path $script:EMERGER_CONFIG 'profiles.d'),
+ (Join-Path $script:EMERGER_ROOT 'share\profiles'))) {
+ if (-not (Test-Path $d)) { continue }
+ Get-ChildItem $d -Filter '*.ps1' -ErrorAction SilentlyContinue | ForEach-Object {
+ $n = $_.BaseName
+ if ($seen.ContainsKey($n)) { return }
+ $seen[$n] = $true
+ $descLine = Select-String -Path $_.FullName -Pattern '^# description:' -ErrorAction SilentlyContinue | Select-Object -First 1
+ $desc = ''
+ if ($descLine) { $desc = $descLine.Line -replace '^# description:\s*','' }
+ Write-Host " $(UI-Color cyan $n) $(UI-Color gray $desc)"
+ }
+ }
+}
diff --git a/src/pslib/Summary.ps1 b/src/pslib/Summary.ps1
new file mode 100644
index 0000000..f50b0a6
--- /dev/null
+++ b/src/pslib/Summary.ps1
@@ -0,0 +1,130 @@
+# Final summary box, history persistence, errors/history viewers.
+
+$script:SUMMARY_FREED_MB = 0
+
+function Summary-Json {
+ param([int]$Duration)
+ $obj = [pscustomobject]@{
+ ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ duration = $Duration
+ errors = @($script:Summary | Where-Object { $_.Result -eq 'fail' }).Count
+ freed_mb = $script:SUMMARY_FREED_MB
+ reboot = [int](Reboot-Pending)
+ managers = @($script:Summary | ForEach-Object { [pscustomobject]@{ name = $_.Name; result = $_.Result } })
+ }
+ $obj | ConvertTo-Json -Compress -Depth 5
+}
+
+function Summary-Print {
+ param([int]$Duration)
+ if ($script:ArgsGlobal -and $script:ArgsGlobal.Json) {
+ Summary-Json $Duration
+ _Persist-History $Duration
+ return
+ }
+ if ($script:QUIET_LEVEL -ge 3) { return }
+
+ $min = [int]($Duration / 60)
+ $sec = $Duration % 60
+
+ $okCount = @($script:Summary | Where-Object { $_.Result -eq 'ok' }).Count
+ $failCount = @($script:Summary | Where-Object { $_.Result -eq 'fail' }).Count
+
+ if ($script:QUIET_LEVEL -ge 2) {
+ if ($failCount -gt 0) {
+ Write-Host "$okCount/$($okCount+$failCount) managers ok, $failCount error(s), ${min}m${sec}s"
+ } else {
+ Write-Host "$okCount managers ok, ${min}m${sec}s"
+ }
+ _Persist-History $Duration
+ return
+ }
+
+ $mgrLine = ''
+ foreach ($r in $script:Summary) {
+ if ($r.Result -eq 'ok') { $mgrLine += "$(UI-Color green (UI-Glyph check)) $($r.Name) " }
+ else { $mgrLine += "$(UI-Color red (UI-Glyph cross)) $($r.Name) " }
+ }
+
+ $lines = @()
+ if ($mgrLine) { $lines += $mgrLine; $lines += '' }
+ $lines += "duration: ${min}m${sec}s"
+ if ($script:SUMMARY_FREED_MB -gt 0) {
+ $lines += ("freed: {0} MB" -f $script:SUMMARY_FREED_MB)
+ }
+ if ($failCount -gt 0) {
+ $lines += (UI-Color yellow "$failCount error(s) - up --errors")
+ } else {
+ $lines += (UI-Color green 'no errors')
+ }
+
+ UI-Box 'eMerger summary' $lines
+ _Persist-History $Duration
+ Reboot-Advisory
+}
+
+function _Persist-History {
+ param([int]$Duration)
+ $hist = Join-Path $script:EMERGER_STATE 'history.jsonl'
+ $obj = [pscustomobject]@{
+ ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+ duration = $Duration
+ errors = @($script:Summary | Where-Object { $_.Result -eq 'fail' }).Count
+ freed_mb = $script:SUMMARY_FREED_MB
+ reboot = [int](Reboot-Pending)
+ managers = @($script:Summary | ForEach-Object { [pscustomobject]@{ name = $_.Name; result = $_.Result } })
+ }
+ try {
+ $line = $obj | ConvertTo-Json -Compress -Depth 5
+ Add-Content -Path $hist -Value $line -ErrorAction Stop
+ # rotate
+ $count = (Get-Content $hist | Measure-Object).Count
+ if ($count -gt 500) { Get-Content $hist -Tail 500 | Set-Content $hist }
+ } catch {}
+}
+
+function Reboot-Pending {
+ # Several signals can indicate a pending reboot on Windows.
+ $keys = @(
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired',
+ 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations'
+ )
+ foreach ($k in $keys) {
+ if (Test-Path $k) { return $true }
+ }
+ return $false
+}
+
+function Reboot-Advisory {
+ if (Reboot-Pending) {
+ Write-Host ("`n $(UI-Color yellow (UI-Glyph warn)) $(UI-Color yellow 'REBOOT RECOMMENDED') $(UI-Color gray '(pending updates require restart)')")
+ Write-Host (" $(UI-Color gray 'run:') Restart-Computer")
+ }
+}
+
+function Show-Errors {
+ $log = Join-Path $script:EMERGER_STATE 'emerger.log'
+ if (-not (Test-Path $log)) { UI-Ok 'no log yet'; return }
+ $errs = @(Select-String -Path $log -Pattern '\|ERROR\|' -ErrorAction SilentlyContinue)
+ if ($errs.Count -eq 0) { UI-Ok 'no errors logged'; return }
+ UI-Warn "$($errs.Count) error line(s):"
+ $errs | Select-Object -Last 30 | ForEach-Object { Write-Host " $($_.Line)" }
+}
+
+function Show-History {
+ $hist = Join-Path $script:EMERGER_STATE 'history.jsonl'
+ if (-not (Test-Path $hist)) { UI-Muted 'no history yet'; return }
+ UI-Title 'Recent runs'
+ Get-Content $hist -Tail 10 | ForEach-Object {
+ try {
+ $o = $_ | ConvertFrom-Json
+ $reboot = if ($o.reboot -eq 1) { ' ' + (UI-Color yellow 'reboot') } else { '' }
+ if ($o.errors -gt 0) {
+ Write-Host " $(UI-Color red (UI-Glyph cross)) $($o.ts) $($o.duration)s $(UI-Color yellow "errors=$($o.errors)")$reboot"
+ } else {
+ Write-Host " $(UI-Color green (UI-Glyph check)) $($o.ts) $($o.duration)s$reboot"
+ }
+ } catch {}
+ }
+}
diff --git a/src/pslib/Sys.ps1 b/src/pslib/Sys.ps1
new file mode 100644
index 0000000..5cf0d5e
--- /dev/null
+++ b/src/pslib/Sys.ps1
@@ -0,0 +1,55 @@
+# Platform helpers.
+
+function Sys-Has {
+ param([string]$Cmd)
+ return [bool](Get-Command $Cmd -ErrorAction SilentlyContinue)
+}
+
+function Sys-OS {
+ $os = [Environment]::OSVersion
+ "Windows $($os.Version)"
+}
+
+function Sys-Arch { return $env:PROCESSOR_ARCHITECTURE }
+
+function Sys-IsAdmin {
+ try {
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
+ $p = [Security.Principal.WindowsPrincipal]::new($id)
+ return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+ } catch {
+ return $false
+ }
+}
+
+function Sys-Start-Elevated {
+ param([string]$ScriptPath, [string[]]$Arguments)
+ $argStr = ($Arguments | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' '
+ Start-Process -FilePath 'powershell' `
+ -ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$ScriptPath`"", $argStr `
+ -Verb RunAs -Wait
+}
+
+function Sys-On-Battery {
+ try {
+ $b = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
+ if ($b -and $b.BatteryStatus -eq 1) { return $true }
+ } catch {}
+ return $false
+}
+
+function Sys-Battery-Percent {
+ try {
+ $b = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
+ if ($b) { return [int]$b.EstimatedChargeRemaining }
+ } catch {}
+ return 100
+}
+
+function Sys-Disk-Free-GB {
+ param([string]$Drive = 'C')
+ try {
+ $d = Get-PSDrive $Drive -ErrorAction Stop
+ return [math]::Round($d.Free / 1GB, 1)
+ } catch { return 0 }
+}
diff --git a/src/pslib/UI.ps1 b/src/pslib/UI.ps1
new file mode 100644
index 0000000..a91c985
--- /dev/null
+++ b/src/pslib/UI.ps1
@@ -0,0 +1,115 @@
+# eMerger Windows UI helpers: colors, glyphs, box, step, spinner.
+# Stays in script scope; all state is kept on $script:.
+
+$script:UI_COLOR = $true
+if ($env:NO_COLOR) { $script:UI_COLOR = $false }
+try { if ([Console]::IsOutputRedirected) { $script:UI_COLOR = $false } } catch {}
+
+$script:UI_UNICODE = -not [bool]$env:EMERGER_NO_EMOJI
+$script:QUIET_LEVEL = 0
+$script:UI_VERBOSE = $false
+
+function UI-Color {
+ param([string]$Color, [string]$Text)
+ if (-not $script:UI_COLOR) { return $Text }
+ $esc = [char]27
+ $map = @{ red=91; green=92; yellow=93; blue=94; magenta=95; cyan=96; gray=90; bold=1; dim=2 }
+ $c = $map[$Color]
+ if (-not $c) { return $Text }
+ return "$esc[${c}m$Text$esc[0m"
+}
+
+function UI-Glyph {
+ param([string]$Name)
+ if ($script:UI_UNICODE) {
+ switch ($Name) {
+ 'check' { 'โ' }
+ 'cross' { 'โ' }
+ 'arrow' { 'โถ' }
+ 'info' { 'โน' }
+ 'warn' { 'โ ' }
+ 'dot' { 'ยท' }
+ 'bullet' { 'โข' }
+ default { '*' }
+ }
+ } else {
+ switch ($Name) {
+ 'check' { '[OK]' }
+ 'cross' { '[X]' }
+ 'arrow' { '>' }
+ 'info' { 'i' }
+ 'warn' { '!' }
+ 'dot' { '.' }
+ 'bullet' { '*' }
+ default { '*' }
+ }
+ }
+}
+
+function UI-Width {
+ try { $w = [Console]::WindowWidth } catch { $w = 80 }
+ if ($w -lt 20) { $w = 80 }
+ if ($w -gt 80) { return 80 }
+ return $w
+}
+
+function UI-Hr {
+ if ($script:QUIET_LEVEL -ge 2) { return }
+ $w = UI-Width
+ Write-Host (UI-Color gray ('โ' * $w))
+}
+
+function UI-Title {
+ param([string]$Text)
+ if ($script:QUIET_LEVEL -ge 2) { return }
+ Write-Host ""
+ Write-Host ("$(UI-Color cyan (UI-Glyph arrow)) $(UI-Color cyan (UI-Color bold $Text))")
+ UI-Hr
+}
+
+function UI-Info { param([string]$T) if ($script:QUIET_LEVEL -lt 2) { Write-Host " $(UI-Color blue (UI-Glyph info)) $T" } }
+function UI-Ok { param([string]$T) if ($script:QUIET_LEVEL -lt 2) { Write-Host " $(UI-Color green (UI-Glyph check)) $T" } }
+function UI-Warn { param([string]$T) Write-Host " $(UI-Color yellow (UI-Glyph warn)) $T" }
+function UI-Err { param([string]$T) [Console]::Error.WriteLine(" $(UI-Color red (UI-Glyph cross)) $T") }
+function UI-Muted { param([string]$T) if ($script:QUIET_LEVEL -lt 1) { Write-Host (UI-Color gray " $T") } }
+function UI-Sub { param([string]$T) if ($script:QUIET_LEVEL -lt 1) { Write-Host " $(UI-Color gray (UI-Glyph dot)) $T" } }
+function UI-Done { param([string]$T) if ($script:QUIET_LEVEL -lt 2) { Write-Host " $(UI-Color green (UI-Glyph check)) $T" } }
+function UI-Fail { param([string]$T,[int]$Rc=1) Write-Host " $(UI-Color red (UI-Glyph cross)) $T $(UI-Color gray "(rc=$Rc)")" }
+
+function UI-Step {
+ param([int]$I,[int]$N,[string]$Text)
+ if ($script:QUIET_LEVEL -ge 2) { return }
+ Write-Host ""
+ Write-Host "$(UI-Color magenta "[$I/$N]") $(UI-Color bold $Text)"
+}
+
+function UI-Logo {
+ param([string]$Path)
+ if ($script:QUIET_LEVEL -ge 1) { return }
+ if (-not (Test-Path $Path)) { return }
+ if ((UI-Width) -lt 60) { return }
+ Write-Host (UI-Color cyan (Get-Content $Path -Raw))
+}
+
+function UI-Box {
+ param([string]$Title, [string[]]$Lines)
+ if ($script:QUIET_LEVEL -ge 3) { return }
+ $strip = { param($s) [regex]::Replace($s, "`e\[[0-9;]*[a-zA-Z]", '') }
+ $maxlen = $Title.Length
+ foreach ($l in $Lines) {
+ $len = (& $strip $l).Length
+ if ($len -gt $maxlen) { $maxlen = $len }
+ }
+ if ($maxlen -lt 30) { $maxlen = 30 }
+ $bar = 'โ' * ($maxlen + 2)
+ Write-Host ""
+ Write-Host " $(UI-Color cyan "โญ$barโฎ")"
+ Write-Host (" $(UI-Color cyan 'โ') " + (UI-Color bold ($Title.PadRight($maxlen))) + " $(UI-Color cyan 'โ')")
+ Write-Host " $(UI-Color cyan "โ$barโ")"
+ foreach ($l in $Lines) {
+ $stripped = & $strip $l
+ $pad = ' ' * ($maxlen - $stripped.Length)
+ Write-Host (" $(UI-Color cyan 'โ') $l$pad $(UI-Color cyan 'โ')")
+ }
+ Write-Host " $(UI-Color cyan "โฐ$barโฏ")"
+}
diff --git a/src/pslib/Update.ps1 b/src/pslib/Update.ps1
new file mode 100644
index 0000000..c31badc
--- /dev/null
+++ b/src/pslib/Update.ps1
@@ -0,0 +1,54 @@
+# Self-update (git) and auto-update (Task Scheduler).
+
+function Self-Update {
+ param([string]$Root)
+ UI-Title 'Self-update'
+ if (-not (Test-Path (Join-Path $Root '.git'))) {
+ UI-Err 'Not a git checkout. Re-clone from https://github.com/MasterCruelty/eMerger'
+ return 1
+ }
+ if (-not (Sys-Has git)) { UI-Err 'git not installed'; return 1 }
+ if ($script:ArgsGlobal.DryRun) { UI-Sub "[dry-run] git -C `"$Root`" pull --ff-only"; return 0 }
+ $before = (& git -C $Root rev-parse HEAD 2>$null).Trim()
+ & git -C $Root fetch --quiet 2>$null
+ & git -C $Root pull --ff-only --quiet 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ UI-Err 'git pull failed (non fast-forward?)'
+ return 1
+ }
+ $after = (& git -C $Root rev-parse HEAD 2>$null).Trim()
+ if ($before -eq $after) {
+ UI-Ok 'Already up to date.'
+ } else {
+ UI-Ok ("Updated {0}..{1}" -f $before.Substring(0, [math]::Min(7,$before.Length)), $after.Substring(0, [math]::Min(7,$after.Length)))
+ & git -C $Root log --oneline "$before..$after" | ForEach-Object { Write-Host " $(UI-Color gray (UI-Glyph dot)) $_" }
+ }
+ return 0
+}
+
+function Setup-Task {
+ param([string]$Root)
+ UI-Title 'Auto-update (Task Scheduler)'
+ if ($script:ArgsGlobal.DryRun) { UI-Sub '[dry-run] would register scheduled task'; return 0 }
+ $entry = Join-Path $Root 'src\emerger.ps1'
+ try {
+ $action = New-ScheduledTaskAction `
+ -Execute 'powershell' `
+ -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$entry`" -y -q -nl -ni"
+ $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At '10:00'
+ $settings = New-ScheduledTaskSettingsSet `
+ -StartWhenAvailable `
+ -RunOnlyIfNetworkAvailable `
+ -RandomDelay (New-TimeSpan -Hours 1) `
+ -DontStopOnIdleEnd
+ $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
+ Register-ScheduledTask -TaskName 'eMerger' -Action $action -Trigger $trigger -Settings $settings -Principal $principal `
+ -Description 'Weekly eMerger update' -Force | Out-Null
+ UI-Ok "Scheduled task 'eMerger' created (weekly, Sunday 10:00, ยฑ1h delay)"
+ UI-Muted "Manage with: Get-ScheduledTask eMerger | Select-Object State,LastRunTime"
+ return 0
+ } catch {
+ UI-Err "Failed to register task: $($_.Exception.Message)"
+ return 1
+ }
+}
diff --git a/src/test/argument_check.sh b/src/test/argument_check.sh
deleted file mode 100755
index 02e5bda..0000000
--- a/src/test/argument_check.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/bin/bash
-
-CONTAIN=0
-ARGS=(
- "-help"
- "-au"
- "-err"
- "-ni"
- "-nl"
- "-nt"
- "-rc"
- "-up"
- "-w"
- "-xyzzy"
-)
-
-for ARGU in $@; do
- if [[ ${ARGS[*]} =~ $ARGU ]]; then
- CONTAIN=1
- else
- CONTAIN=0
- break
- fi
-done
-
-if [[ $CONTAIN != 1 ]]; then
- puts NC "No such command, try \"up -help\"\n"
- exit 1
-fi
diff --git a/src/test/integrity_check.sh b/src/test/integrity_check.sh
deleted file mode 100755
index b656ec0..0000000
--- a/src/test/integrity_check.sh
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/bin/bash
-
-# REF looks for where the file has been executed
-# If the script is updated by the cronjob, it contains an argument $1
-# If the script is executed from `test/`, then no PAD is added
-# If the script is executed from '.setup.sh', then it needs to know the PAD
-SRC=""
-if [[ $1 != "" ]]; then
- SRC="$1src"
-else
- REF=$(dirname "$(readlink -f ../$0)")
- PAD=""
- if [[ $REF != *"/eMerger/src" ]]; then
- PAD="/eMerger/src"
- fi
- SRC="$(cat $REF$PAD/utils/.cache | head -n 1)/src"
-fi
-
-# check global.sh existence (puts is there)
-if [[ -f $SRC/utils/global.sh ]]; then
- source $SRC/utils/global.sh
-else
- puts RED "\n$SRC/utils/global.sh is missing: aborting script\n"
- exit 1
-fi
-
-# check ./src/emerger.sh existence
-if [[ -f "$SRC/emerger.sh" ]]; then
- true
-else
- puts NC "emerger.sh is missing: aborting script\n"
- exit 1
-fi
-
-LIST=$SRC/test/list/
-
-# check existence of ./src/utils/*
-puts RED "\ncheck ./src/utils/*: starting"
-while read LINE; do
- if [[ -f "$SRC/utils/$LINE" && $LINE != "" ]]; then
- puts LOGO "passed\t$LINE $NORMAL$GREEN$CHECKMARK"
- else
- puts RED "$SRC/utils/$LINE is missing: aborting script $CROSSMARK"
- exit 1
- fi
-done < $LIST.utils
-puts GREEN "check ./src/utils/*: completed"
-
-# check existence of ./src/package/*
-puts RED "check ./src/package/*: starting"
-while read LINE; do
- if [[ -f "$SRC/package/$LINE" && $LINE != "" ]]; then
- puts LOGO "passed\t$LINE $NORMAL$GREEN$CHECKMARK"
- else
- puts RED "$SRC/package/$LINE is missing: aborting script $CROSSMARK"
- exit 1
- fi
-done < $LIST.packages
-puts GREEN "check ./src/package/*: completed"
-
-# check existence of ./src/test/*
-puts RED "check ./src/test/*: starting"
-while read LINE; do
- if [[ -f "$SRC/test/$LINE" && $LINE != "" ]]; then
- puts LOGO "passed\t$LINE $NORMAL$GREEN$CHECKMARK"
- else
- puts RED "$LINE is missing: aborting script $CROSSMARK"
- exit 1
- fi
-done < $LIST.tests
-puts GREEN "check ./src/test/*: completed\n"
diff --git a/src/test/list/.packages b/src/test/list/.packages
deleted file mode 100644
index 6ef3924..0000000
--- a/src/test/list/.packages
+++ /dev/null
@@ -1,9 +0,0 @@
-arch.sh
-debian.sh
-flatpak.sh
-gentoo.sh
-nix.sh
-opensuse.sh
-rpm.sh
-snap.sh
-termux.sh
diff --git a/src/test/list/.tests b/src/test/list/.tests
deleted file mode 100644
index 35b87a9..0000000
--- a/src/test/list/.tests
+++ /dev/null
@@ -1 +0,0 @@
-argument_check.sh
diff --git a/src/test/list/.utils b/src/test/list/.utils
deleted file mode 100644
index 9461721..0000000
--- a/src/test/list/.utils
+++ /dev/null
@@ -1,7 +0,0 @@
-cache_gen.sh
-cache.sh
-check_pwr.sh
-global.sh
-help
-privileges.sh
-trash.sh
diff --git a/src/utils/.logo b/src/utils/.logo
deleted file mode 100644
index 78ecb0c..0000000
--- a/src/utils/.logo
+++ /dev/null
@@ -1,15 +0,0 @@
-
- $$\ $$\
- $$$\ $$$ |
- $$$$$$\ $$$$\ $$$$ | $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
- $$ __$$\ $$\$$\$$ $$ |$$ __$$\ $$ __$$\ $$ __$$\ $$ __$$\ $$ __$$\
- $$$$$$$$ |$$ \$$$ $$ |$$$$$$$$ |$$ | \__|$$ / $$ |$$$$$$$$ |$$ | \__|
- $$ ____|$$ |\$ /$$ |$$ ____|$$ | $$ | $$ |$$ ____|$$ |
- \$$$$$$$\ $$ | \_/ $$ |\$$$$$$$\ $$ | \$$$$$$$ |\$$$$$$$\ $$ |
- \_______|\__| \__| \_______|\__| \____$$ | \_______|\__|
- $$\ $$ |
- \$$$$$$ |
- \______/
-
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-MIT License
diff --git a/src/utils/cache.sh b/src/utils/cache.sh
deleted file mode 100755
index d80e342..0000000
--- a/src/utils/cache.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/bash
-
-source $(dirname "$(readlink -f "$0")")/utils/global.sh
-
-if [[ -d ~/.cache ]]; then
- puts RED "Showing files in .cache $TRASH"
- du -sh ~/.cache
- ls -Al ~/.cache | tail -n +2
- puts NC "Should I clean caches? "
- read -p "[Y/n]: " ANSW
- if [[ $ANSW == "n" ]]; then
- puts RED "Caches: not cleaned"
- else
- rm -rf ~/.cache*
- puts GREEN "Caches: cleaned"
- fi
-else
- puts GREEN "Caches are empty, nothing to clean"
-fi
diff --git a/src/utils/cache_gen.sh b/src/utils/cache_gen.sh
deleted file mode 100755
index c2b5600..0000000
--- a/src/utils/cache_gen.sh
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/bin/bash
-
-### generates default caches ###
-
-# path
-OUT="$(pwd | awk -F 'eMerger/src/utils' '{print $1}')\n"
-
-# check for first available terminal
-declare -a TERMINAL
-TERMINAL=(
- "xfce4-terminal"
- "xterm"
- "konsole"
- "terminator"
- "lxterminal"
- "gnome-terminal"
-)
-
-# this flag goes to 1 if we have a known terminal
-FLAG=0
-for i in ${TERMINAL[@]}; do
- if [[ $(command -v $i) ]]; then
- OUT+="$i\n"
- FLAG=1
- break
- fi
-done
-
-if [[ $FLAG -eq 0 ]]; then
- OUT+="unknown\n"
-fi
-
-# package managers dictionary
-declare -A PKG
-PKG=(
- ["pacman"]="arch"
- ["apt-get"]="debian"
- ["flatpak"]="flatpak"
- ["emerge"]="gentoo"
- ["nixos-rebuild"]="nix"
- ["zypper"]="opensuse"
- ["rpm"]="rpm"
- ["snap"]="snap"
- ["pkg"]="termux"
-)
-
-# check for privileges
-if [[ $(command -v pkg) ]]; then
- :
-else
- OUT+="utils/privileges\n"
-fi
-
-# check for available package manager
-for i in ${!PKG[@]}; do
- if [[ $(command -v $i) ]]; then
- OUT+="package/${PKG[$i]}\n"
- fi
-done
-
-OUT+="utils/cache\n"
-OUT+="utils/trash"
-
-echo -e "$OUT"
diff --git a/src/utils/check_pwr.sh b/src/utils/check_pwr.sh
deleted file mode 100755
index 755810d..0000000
--- a/src/utils/check_pwr.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-PWR=""
-if [[ $(grep -c "utils/privileges" $(dirname "$(readlink -f "$0")")/utils/.cache) -gt 0 ]]; then
- PWR="sudo"
-fi
-
-echo "$PWR"
\ No newline at end of file
diff --git a/src/utils/global.sh b/src/utils/global.sh
deleted file mode 100755
index 0e36fa3..0000000
--- a/src/utils/global.sh
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/bin/bash
-
-# Colors
-BLUE="\e[1;34;49m"
-GREEN="\e[1;32;49m"
-LOGO="\e[2;37;49m"
-NORMAL="\e[0m"
-RED="\e[1;91;49m"
-
-# Emojis
-ARCH="\U0001F3F9"
-CHECKMARK="\U00002714"
-COOL="\U0001F60E"
-CROSSMARK="\U0000274C"
-DEBIAN="\U0001F300"
-FLATPAK="\U0001F4E6"
-GENTOO="\U0001F427"
-NIX="\U00002744"
-MONOCLE="\U0001F9D0"
-OPENSUSE="\U0001F98E"
-RPM="\U0001F920"
-SAD="\U0001F622"
-SCROLL="\U0001F4DC"
-SNAP="\U0001F9A2"
-TERMUX="\U0001F916"
-TRASH="\U0001F4A9"
-WHALE="\U0001F40B"
-
-# Argument $1 is color, argument $2 is text
-function puts() {
- case $1 in
- BLUE)
- printf "$BLUE$2$NORMAL\n"
- ;;
- GREEN)
- echo "$GREEN$2$NORMAL" >> $SRC/.hist
- HIST=$(cat $SRC/.hist)
- clear
- printf "$GREEN$HIST$NORMAL\n"
- ;;
- LOGO)
- printf "$LOGO$2$NORMAL\n"
- ;;
- NC)
- printf "$2\n"
- ;;
- RED)
- printf "$RED$2$NORMAL\n"
- ;;
- *)
- printf "$2\n"
- ;;
- esac
-}
-
-# Execute and stops in case it fails
-# DO NOT ADD [ ... ] brackets
-function try() {
- if $@; then
- continue
- else
- puts RED "STOPPED AT '$(for i in "$@"; do echo -n "$i "; done)'"
- exit 1
- fi
-}
diff --git a/src/utils/help b/src/utils/help
deleted file mode 100644
index bbf2c89..0000000
--- a/src/utils/help
+++ /dev/null
@@ -1,10 +0,0 @@
--help Shows this help
-
--err Shows errors (ERRors)
--nc Skips cache cleaning. (No Cache)
--ni Skips system informations. (No Infos)
--nl Skips logo. (No Logo)
--nt Skips trash cleaning. (No Trash)
--rc Recreate caches (Recreate Cache)
--up Updates the updater (UPdate)
--w Shows weather under logo. (Weather)
diff --git a/src/utils/privileges.sh b/src/utils/privileges.sh
deleted file mode 100755
index c5a61dd..0000000
--- a/src/utils/privileges.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-
-source $(dirname "$(readlink -f "$0")")/utils/global.sh
-
-puts RED "\nChecking for sudo privileges $MONOCLE"
-sudo -v >/dev/null 2>&1
-if [[ $(echo $?) -eq 0 ]]; then
- puts GREEN "Access granted\n"
-else
- puts RED "Can't access: aborting script\n"
- exit 1
-fi
diff --git a/src/utils/trash.sh b/src/utils/trash.sh
deleted file mode 100755
index 87e4bf0..0000000
--- a/src/utils/trash.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/bash
-
-source $(dirname "$(readlink -f "$0")")/utils/global.sh
-
-if [[ -d ~/.local/share/Trash/files ]]; then
- puts RED "Showing files in .local/share/Trash/files $TRASH"
- du -sh ~/.local/share/Trash/files
- ls -Al ~/.local/share/Trash/files | tail -n +2
- puts NC "Should I clean Trash? "
- read -p "[Y/n]: " ANSW
- if [[ $ANSW == "n" ]]; then
- puts RED "Trash: not cleaned"
- else
- rm -rf ~/.local/share/Trash/*
- puts GREEN "Trash: cleaned"
- fi
-else
- puts GREEN "Trash is empty, nothing to clean"
-fi
diff --git a/tests/emerger.bats b/tests/emerger.bats
new file mode 100644
index 0000000..50b9d26
--- /dev/null
+++ b/tests/emerger.bats
@@ -0,0 +1,91 @@
+#!/usr/bin/env bats
+
+setup() {
+ export REPO_DIR="${BATS_TEST_DIRNAME}/.."
+ export EMERGER="bash $REPO_DIR/src/emerger.sh"
+ export HOME_BAK="$HOME"
+ export HOME="$(mktemp -d)"
+ export XDG_CACHE_HOME="$HOME/.cache"
+ export XDG_CONFIG_HOME="$HOME/.config"
+ export XDG_STATE_HOME="$HOME/.local/state"
+ export NO_COLOR=1
+ mkdir -p "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME" "$XDG_STATE_HOME"
+}
+
+teardown() {
+ rm -rf "$HOME"
+ export HOME="$HOME_BAK"
+}
+
+@test "help exits 0 and mentions USAGE" {
+ run $EMERGER --help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"USAGE"* ]]
+}
+
+@test "version prints version string" {
+ run $EMERGER --version
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"eMerger"* ]]
+}
+
+@test "unknown flag fails with exit code 2" {
+ run $EMERGER --totally-bogus
+ [ "$status" -eq 2 ]
+}
+
+@test "short no-logo flag is parsed correctly (not as -n)" {
+ run $EMERGER --help -nl
+ [ "$status" -eq 0 ]
+}
+
+@test "errors shows ok on empty log" {
+ run $EMERGER --errors
+ [ "$status" -eq 0 ]
+}
+
+@test "history shows placeholder when empty" {
+ run $EMERGER --history
+ [ "$status" -eq 0 ]
+}
+
+@test "dry-run completes without sudo" {
+ run $EMERGER -n -y -nl -ni -nc -nt -q
+ [ "$status" -eq 0 ]
+}
+
+@test "short flag bundling: -nv is accepted" {
+ run $EMERGER -nv -y -nl -ni -nc -nt -q
+ [ "$status" -eq 0 ]
+}
+
+@test "json mode emits a single json object on stdout" {
+ run $EMERGER -n -y --json
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"managers":'* ]]
+ [[ "$output" == *'"errors":'* ]]
+}
+
+@test "--only filter restricts managers" {
+ run $EMERGER -n -y --json --only apt
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"apt"'* ]]
+ [[ "$output" != *'"snap"'* ]]
+}
+
+@test "--except filter skips managers" {
+ run $EMERGER -n -y --json --except snap
+ [ "$status" -eq 0 ]
+ [[ "$output" != *'"snap"'* ]]
+}
+
+@test "--metrics refuses with empty history" {
+ run $EMERGER --metrics "$HOME/out.prom"
+ # Either exits non-zero or writes the file; both behaviors are fine here.
+ [ "$status" -eq 0 ] || [ "$status" -eq 1 ]
+}
+
+@test "unknown long flag still fails with exit code 2" {
+ run $EMERGER --no-such-flag
+ [ "$status" -eq 2 ]
+}
diff --git a/uninstall.ps1 b/uninstall.ps1
new file mode 100644
index 0000000..43ed77f
--- /dev/null
+++ b/uninstall.ps1
@@ -0,0 +1,45 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ Remove the eMerger Windows integration: `up` function, scheduled task.
+ Config and state under %APPDATA%\emerger and %LOCALAPPDATA%\emerger are kept.
+#>
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$REPO = Split-Path -Parent $PSCommandPath
+. "$REPO\src\pslib\UI.ps1"
+
+UI-Title 'eMerger uninstall (Windows)'
+
+$profilePath = $PROFILE.CurrentUserAllHosts
+if (Test-Path $profilePath) {
+ $content = Get-Content $profilePath
+ if ($content -match 'emerger\.ps1') {
+ $filtered = $content | Where-Object {
+ $_ -notmatch 'emerger\.ps1' -and
+ $_ -notmatch '^# eMerger\b' -and
+ $_ -notmatch '^\s*function\s+up\s*\{'
+ }
+ $filtered | Set-Content $profilePath -Encoding UTF8
+ UI-Ok "Cleaned $profilePath"
+ } else {
+ UI-Muted "No eMerger entry in $profilePath"
+ }
+}
+
+# Scheduled task.
+try {
+ $t = Get-ScheduledTask -TaskName 'eMerger' -ErrorAction Stop
+ if ($t) {
+ Unregister-ScheduledTask -TaskName 'eMerger' -Confirm:$false
+ UI-Ok "Removed scheduled task 'eMerger'"
+ }
+} catch {}
+
+UI-Muted "Config kept at: $env:APPDATA\emerger\"
+UI-Muted "State kept at: $env:LOCALAPPDATA\emerger\"
+UI-Muted "Delete manually if you want a truly clean wipe."
+
+UI-Ok "Uninstall complete. Repo still at $REPO."
diff --git a/uninstall.sh b/uninstall.sh
index 9ea4f43..10522b9 100755
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -1,37 +1,42 @@
-#!/bin/bash
-
-source src/utils/global.sh 2>>.log
-
-if [[ $(grep -c "alias up=" ~/.bashrc) -lt 1 ]]; then
- puts GREEN "Nothing to uninstall $CHECKMARK"
- exit 0
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+
+# shellcheck source=src/lib/ui.sh
+source "$REPO_DIR/src/lib/ui.sh"
+# shellcheck source=src/lib/sys.sh
+source "$REPO_DIR/src/lib/sys.sh"
+
+ui_title "eMerger uninstall"
+
+removed_any=0
+for rc in "$HOME/.bashrc" "${ZDOTDIR:-$HOME}/.zshrc" "$HOME/.config/fish/config.fish"; do
+ [[ -f $rc ]] || continue
+ if grep -q "emerger.sh" "$rc"; then
+ sed -i.bak '/emerger\.sh/d;/# eMerger/d' "$rc"
+ rm -f "$rc.bak"
+ ui_ok "Cleaned $rc"
+ removed_any=1
+ fi
+done
+(( removed_any )) || ui_muted "No shell rc entries found."
+
+if sys_has crontab && crontab -l 2>/dev/null | grep -q "emerger.sh"; then
+ crontab -l 2>/dev/null | grep -v "emerger.sh" | crontab -
+ ui_ok "Removed cronjob"
fi
-puts RED "Uninstall: starting"
-sed -i "/alias up=/d" ~/.bashrc 2>>.log
-puts LOGO "Alias 'up' removed"
-
-# Get favourite terminal
-TERMINAL=$(cat src/utils/.cache | head -n 2 | tail -n 1)
-
-# Remove cronjob, if it exists (and if the user can)
-if [[ $(grep -c "utils/privileges" $(dirname "$(readlink -f "$0")")/src/utils/.cache) -gt 0 && $(crontab -l | grep -c "eMerger/update.sh") -gt 0 ]]; then
- sudo crontab -u $USER -l | grep -v "eMerger/update.sh" | sudo crontab -u $USER -
- puts LOGO "Cronjob successfully removed"
+if sys_has systemctl && systemctl --user list-unit-files 2>/dev/null | grep -q emerger; then
+ systemctl --user disable --now emerger.timer 2>/dev/null || true
+ rm -f "$HOME/.config/systemd/user/emerger.service" "$HOME/.config/systemd/user/emerger.timer"
+ systemctl --user daemon-reload 2>/dev/null || true
+ ui_ok "Removed systemd user timer"
fi
-# Remove .cache and .md5
-rm -f src/utils/.cache 2>>.log
-rm -f src/utils/.md5 2>>.log
-
-puts GREEN "Uninstall: completed $SAD"
+ui_muted "State and config kept at:"
+ui_muted " ${XDG_CONFIG_HOME:-$HOME/.config}/emerger/"
+ui_muted " ${XDG_STATE_HOME:-$HOME/.local/state}/emerger/"
+ui_muted "Remove manually if you want them gone."
-if [[ $TERMINAL == "unknown" ]]; then
- exec bash
- exit 0
-else
- read -p "$(echo -e ${RED}Press enter, this process will be killed${NORMAL})"
-
- $TERMINAL 2>>.log
- kill -9 $PPID
-fi
+ui_ok "Uninstall complete. Repo still at $REPO_DIR."
diff --git a/update.ps1 b/update.ps1
new file mode 100644
index 0000000..65db7bf
--- /dev/null
+++ b/update.ps1
@@ -0,0 +1,5 @@
+#Requires -Version 5.1
+# Thin wrapper for `up --self-update`.
+param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)
+$REPO = Split-Path -Parent $PSCommandPath
+& "$REPO\src\emerger.ps1" --self-update @Arguments
diff --git a/update.sh b/update.sh
index c17b1a1..88a21e5 100755
--- a/update.sh
+++ b/update.sh
@@ -1,20 +1,5 @@
-#!/bin/bash
-
-# Keeping track of exact time
-date "+%D %T:%N" >> $1.log
-
-source $1src/utils/global.sh
-
-# git pull from main
-puts RED "Update repository: starting"
-if [[ $1 != "" ]]; then
- git -C $1 pull https://github.com/MasterCruelty/eMerger.git/
-else
- git pull https://github.com/MasterCruelty/eMerger.git/
-fi
-
-# Instead of re-installing, use our tests to check if everything is okay
-source $1src/test/integrity_check.sh $1 2>>$1.log
-puts GREEN "Update repository: completed"
-
-exit 0
+#!/usr/bin/env bash
+# Thin wrapper; delegates to `up --self-update`.
+set -Eeuo pipefail
+REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+exec bash "$REPO_DIR/src/emerger.sh" --self-update "$@"