diff --git a/AGENTS.md b/AGENTS.md index bbca0b91..e2aee0d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,10 +175,17 @@ This copies `/etc/skel/.config/hypr/hyprlock.conf` to `~/.config/hypr/hyprlock.c # Migrations -To create a new migration, run `omarchy-dev-add-migration --no-edit`. This creates a migration file named after the unix timestamp of the last commit. +Read `docs/migrations.md` before creating or changing migrations. + +Migrations are split by execution scope: + +- `migrations/system/.sh` — root, noninteractive, safe to run from pacman via `omarchy-migrate-system` (`omarchy` `post_upgrade` calls it). Use for `/etc`, `/usr`, `/boot`, services, hardware quirks, and other system state. Do not prompt. +- `migrations/user/.sh` — current user/session, may touch `~/.config`, `~/.local`, user systemd, browser prefs, DBus/session state, and may prompt if necessary. Runs through `omarchy-migrate` / `omarchy-migrate-user`; pending state is per-user based on missing files under `~/.local/state/omarchy/migrations/user/`. + +To create a new migration, run `omarchy-dev-add-migration system --no-edit` or `omarchy-dev-add-migration user --no-edit` based on scope. New migration format: -- File permissions must be `0644` (`-rw-r--r--`); migrations are sourced, not executed directly +- File permissions must be `0644` (`-rw-r--r--`); migration runners execute them with `bash -euo pipefail`, not through executable bits - No shebang line - Start with an `echo` describing what the migration does - Use `$OMARCHY_PATH` to reference the omarchy directory diff --git a/bin/omarchy-dev-add-migration b/bin/omarchy-dev-add-migration index 5446666f..079e7f08 100755 --- a/bin/omarchy-dev-add-migration +++ b/bin/omarchy-dev-add-migration @@ -1,12 +1,29 @@ #!/bin/bash # omarchy:summary=Create a new Omarchy migration in the current source tree. +# omarchy:args= [--no-edit] set -euo pipefail +scope="${1:-}" +case "$scope" in + system|user) + shift + ;; + -h|--help|"") + echo "Usage: omarchy-dev-add-migration [--no-edit]" + exit 0 + ;; + *) + echo "Unknown migration scope: $scope" >&2 + echo "Usage: omarchy-dev-add-migration [--no-edit]" >&2 + exit 1 + ;; +esac + cd "${OMARCHY_PATH:-$(pwd)}" -mkdir -p migrations -migration_file="migrations/$(git log -1 --format=%cd --date=unix).sh" +mkdir -p "migrations/$scope" +migration_file="migrations/$scope/$(git log -1 --format=%cd --date=unix).sh" touch "$migration_file" if [[ ${1:-} != "--no-edit" ]]; then diff --git a/bin/omarchy-finalize-user b/bin/omarchy-finalize-user index 2dc32c3d..1775c3f2 100755 --- a/bin/omarchy-finalize-user +++ b/bin/omarchy-finalize-user @@ -18,7 +18,7 @@ For shipped configs see /etc/skel (new users) and omarchy-reinstall-configs (existing users explicitly resyncing). --first-install is used by the ISO in the target chroot. It marks shipped -migrations complete for the freshly-created user. +user migrations complete for the freshly-created user. Idempotency marker: ~/.local/state/omarchy/finalize-user.done USAGE @@ -123,9 +123,9 @@ xdg-settings set default-web-browser chromium.desktop xdg-mime default HEY.desktop x-scheme-handler/mailto if (( first_install )); then - mkdir -p "$state_dir/migrations" - for migration in "$OMARCHY_PATH"/migrations/*.sh; do - [[ -f $migration ]] && touch "$state_dir/migrations/$(basename "$migration")" + mkdir -p "$state_dir/migrations/user" + for migration in "$OMARCHY_PATH"/migrations/user/*.sh; do + [[ -f $migration ]] && touch "$state_dir/migrations/user/$(basename "$migration")" done fi diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run index 924eeda8..6f719d53 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-first-run @@ -92,6 +92,17 @@ run_first_run_step() { fi } +wait_for_notifications + +MIGRATION_NOTIFY_WATCH_MARKER="$state_dir/user-migration-notify-watch-enabled" +if [[ ! -f $MIGRATION_NOTIFY_WATCH_MARKER || $force -eq 1 ]]; then + if systemctl --user enable --now omarchy-update-user-notify.path >/dev/null 2>&1; then + touch "$MIGRATION_NOTIFY_WATCH_MARKER" + fi +fi + +run_first_run_step "notify about pending user migrations" omarchy-migrate-notify + USER_MARKER="$state_dir/first-run-user.done" if [[ ! -f $USER_MARKER || $force -eq 1 ]]; then run_first_run_step "install Voxtype post-update hook" \ diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index 10d4d57c..e087624f 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -1,19 +1,97 @@ #!/bin/bash -# omarchy:summary=Run pending Omarchy migrations for the current user. +# omarchy:summary=Run pending Omarchy system and user migrations. +# omarchy:args=[--pending [all|system|user]] set -euo pipefail -STATE_DIR="$HOME/.local/state/omarchy/migrations" -mkdir -p "$STATE_DIR" +mode="run" +pending_scope="all" -for file in "$OMARCHY_PATH"/migrations/*.sh; do - [[ -f $file ]] || continue - filename=$(basename "$file") +usage() { + echo "Usage: omarchy-migrate [--pending [all|system|user]]" +} - if [[ ! -f $STATE_DIR/$filename ]]; then - echo -e "\e[32m\nRunning migration (${filename%.sh})\e[0m" - bash "$file" - touch "$STATE_DIR/$filename" - fi +while (($#)); do + case "$1" in + --pending|--check) + mode="pending" + shift + if [[ ${1:-} == "all" || ${1:-} == "system" || ${1:-} == "user" ]]; then + pending_scope="$1" + shift + fi + ;; + --pending=all|--pending=system|--pending=user) + mode="pending" + pending_scope="${1#--pending=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac done + +pending_output="" + +append_pending() { + local scope="$1" + local command="$2" + local output="" + local migration="" + + if output=$("$command" --pending 2>/dev/null); then + while IFS= read -r migration; do + [[ -n $migration ]] || continue + pending_output+="$scope/$migration"$'\n' + done <<<"$output" + fi +} + +if [[ $mode == "pending" ]]; then + case "$pending_scope" in + all) + append_pending system omarchy-migrate-system + append_pending user omarchy-migrate-user + ;; + system) + append_pending system omarchy-migrate-system + ;; + user) + append_pending user omarchy-migrate-user + ;; + esac + + if [[ -n $pending_output ]]; then + printf '%s' "$pending_output" + exit 0 + else + exit 1 + fi +fi + +wait_for_pacman_transaction() { + local lock_file=/var/lib/pacman/db.lck + + [[ -e $lock_file ]] || return 0 + echo "Waiting for pacman transaction to finish before running Omarchy migrations..." + + for _ in {1..900}; do + [[ -e $lock_file ]] || return 0 + sleep 1 + done + + echo "Pacman transaction is still running; Omarchy migrations will retry on next login." + exit 0 +} + +wait_for_pacman_transaction + +omarchy-migrate-system +omarchy-migrate-user diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify new file mode 100755 index 00000000..ab2a365f --- /dev/null +++ b/bin/omarchy-migrate-notify @@ -0,0 +1,35 @@ +#!/bin/bash + +# omarchy:summary=Notify the user when Omarchy has pending user migrations + +set -euo pipefail + +pending_migrations=$(omarchy-migrate --pending user 2>/dev/null) || exit 0 +pending_count=$(printf '%s\n' "$pending_migrations" | sed '/^[[:space:]]*$/d' | wc -l) + +if (( pending_count == 1 )); then + message="1 new Omarchy user migration is available. Click to run it in a terminal." +else + message="$pending_count new Omarchy user migrations are available. Click to run them in a terminal." +fi + +notify_command=$(printf 'if [[ -n $(omarchy-notification-send -u critical -g  "Run Omarchy Migrations" %q -a) ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-migrate; fi' "$message") + +if command -v systemd-run >/dev/null 2>&1; then + unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" + systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 +fi + +print_pending_migrations() { + echo "Omarchy has pending user migrations. Run omarchy-migrate in a terminal to apply them:" + while IFS= read -r migration; do + [[ -n $migration ]] || continue + printf ' %s\n' "$migration" + done <<<"$pending_migrations" +} + +if [[ -t 1 ]]; then + print_pending_migrations +else + print_pending_migrations >&2 +fi diff --git a/bin/omarchy-migrate-system b/bin/omarchy-migrate-system new file mode 100755 index 00000000..8cf109af --- /dev/null +++ b/bin/omarchy-migrate-system @@ -0,0 +1,74 @@ +#!/bin/bash + +# omarchy:summary=Run pending Omarchy system migrations as root. +# omarchy:args=[--pending] +# omarchy:requires-sudo=true + +set -euo pipefail + +mode="run" +while (($#)); do + case "$1" in + --pending|--check) + mode="pending" + shift + ;; + -h|--help) + echo "Usage: omarchy-migrate-system [--pending]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +STATE_DIR="${OMARCHY_SYSTEM_MIGRATION_STATE:-/var/lib/omarchy/migrations/system}" +MIGRATIONS_DIR="$OMARCHY_PATH/migrations/system" + +pending_migrations() { + [[ -d $MIGRATIONS_DIR ]] || return 0 + + for file in "$MIGRATIONS_DIR"/*.sh; do + [[ -f $file ]] || continue + filename=$(basename "$file") + + if [[ ! -f $STATE_DIR/$filename ]]; then + printf '%s\n' "$filename" + fi + done +} + +if [[ $mode == "pending" ]]; then + pending_output=$(pending_migrations) + if [[ -n $pending_output ]]; then + printf '%s\n' "$pending_output" + exit 0 + else + exit 1 + fi +fi + +if (( EUID != 0 )) && [[ -z ${OMARCHY_SYSTEM_MIGRATION_STATE:-} ]]; then + if ! "$0" --pending >/dev/null; then + exit 0 + fi + + exec sudo --preserve-env=OMARCHY_PATH "$0" "$@" +fi + +mkdir -p "$STATE_DIR" +[[ -d $MIGRATIONS_DIR ]] || exit 0 + +for file in "$MIGRATIONS_DIR"/*.sh; do + [[ -f $file ]] || continue + filename=$(basename "$file") + + if [[ ! -f $STATE_DIR/$filename ]]; then + echo -e "\e[32m\nRunning system migration (${filename%.sh})\e[0m" + OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" + touch "$STATE_DIR/$filename" + fi +done diff --git a/bin/omarchy-migrate-user b/bin/omarchy-migrate-user new file mode 100755 index 00000000..cd9a2149 --- /dev/null +++ b/bin/omarchy-migrate-user @@ -0,0 +1,65 @@ +#!/bin/bash + +# omarchy:summary=Run pending Omarchy migrations for the current user. +# omarchy:args=[--pending] + +set -euo pipefail + +mode="run" +while (($#)); do + case "$1" in + --pending|--check) + mode="pending" + shift + ;; + -h|--help) + echo "Usage: omarchy-migrate-user [--pending]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +STATE_DIR="${OMARCHY_USER_MIGRATION_STATE:-$HOME/.local/state/omarchy/migrations/user}" +MIGRATIONS_DIR="$OMARCHY_PATH/migrations/user" + +pending_migrations() { + [[ -d $MIGRATIONS_DIR ]] || return 0 + + for file in "$MIGRATIONS_DIR"/*.sh; do + [[ -f $file ]] || continue + filename=$(basename "$file") + + if [[ ! -f $STATE_DIR/$filename ]]; then + printf '%s\n' "$filename" + fi + done +} + +if [[ $mode == "pending" ]]; then + pending_output=$(pending_migrations) + if [[ -n $pending_output ]]; then + printf '%s\n' "$pending_output" + exit 0 + else + exit 1 + fi +fi + +mkdir -p "$STATE_DIR" +[[ -d $MIGRATIONS_DIR ]] || exit 0 + +for file in "$MIGRATIONS_DIR"/*.sh; do + [[ -f $file ]] || continue + filename=$(basename "$file") + + if [[ ! -f $STATE_DIR/$filename ]]; then + echo -e "\e[32m\nRunning user migration (${filename%.sh})\e[0m" + OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$file" + touch "$STATE_DIR/$filename" + fi +done diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 73ff9054..299d6c20 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -23,4 +23,4 @@ sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirr omarchy-hook pre-refresh-pacman # Reset all package DBs and then update -sudo pacman -Syyuu --noconfirm +sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm diff --git a/bin/omarchy-reinstall-pkgs b/bin/omarchy-reinstall-pkgs index dc64a1f3..c1d51cbf 100755 --- a/bin/omarchy-reinstall-pkgs +++ b/bin/omarchy-reinstall-pkgs @@ -11,8 +11,8 @@ set -e omarchy-refresh-pacman # Downgrade any packages to the stable setup -sudo pacman -Suu --noconfirm +sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Suu --noconfirm # Ensure all packages are installed mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$') -sudo pacman -Syu --noconfirm --needed "${packages[@]}" +sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm --needed "${packages[@]}" diff --git a/bin/omarchy-update b/bin/omarchy-update index e726a8e5..f5727e5e 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -7,14 +7,100 @@ set -e -if [[ -z $OMARCHY_UPDATE_LOGGED ]]; then +if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then script_command=$(printf '%q ' "$0" "$@") exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log" fi +acquire_update_lock() { + local lock_dir="${XDG_RUNTIME_DIR:-/tmp}" + local lock_path="$lock_dir/omarchy-update.lock" + local lock_fd_path="" + + mkdir -p "$lock_dir" 2>/dev/null || true + + if [[ -n ${OMARCHY_UPDATE_LOCK_FD:-} && -e /proc/$$/fd/$OMARCHY_UPDATE_LOCK_FD ]]; then + lock_fd_path=$(readlink -f "/proc/$$/fd/$OMARCHY_UPDATE_LOCK_FD" 2>/dev/null || true) + if [[ $lock_fd_path == "$(readlink -m "$lock_path")" ]] && flock -n "$OMARCHY_UPDATE_LOCK_FD"; then + return 0 + fi + fi + + exec {OMARCHY_UPDATE_LOCK_FD}>"$lock_path" + if ! flock -n "$OMARCHY_UPDATE_LOCK_FD"; then + echo "An Omarchy update is already running." + exit 1 + fi + export OMARCHY_UPDATE_LOCK_FD +} + +update_disabled_idle=0 +sleep_inhibit_pid="" + +disable_sleep_for_update() { + command -v systemd-inhibit >/dev/null 2>&1 || return 0 + + systemd-inhibit \ + --what=sleep:idle \ + --who=omarchy-update \ + --why="Omarchy update in progress" \ + --mode=block \ + sleep infinity >/dev/null 2>&1 & + sleep_inhibit_pid=$! +} + +disable_idle_for_update() { + local stay_awake_state="$HOME/.local/state/omarchy/indicators/stay-awake" + + if [[ ! -f $stay_awake_state ]]; then + omarchy-toggle-idle stay-awake >/dev/null 2>&1 || true + update_disabled_idle=1 + fi +} + +restore_update_inhibitors() { + if (( update_disabled_idle )); then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + fi + + if [[ -n $sleep_inhibit_pid ]]; then + kill "$sleep_inhibit_pid" >/dev/null 2>&1 || true + wait "$sleep_inhibit_pid" >/dev/null 2>&1 || true + fi +} + +run_update_pipeline() { + disable_sleep_for_update + disable_idle_for_update + + omarchy-update-keyring + omarchy-update-system-pkgs + omarchy-migrate + omarchy-hook post-update + omarchy-update-aur-pkgs + omarchy-update-mise + omarchy-update-orphan-pkgs + + omarchy-update-analyze-logs + + # Re-check after updates so the status bar reflects any remaining updates. + # This clears the state when everything is current, so a separate reset command + # is not needed. + if omarchy-update-available >/dev/null; then + omarchy-shell -q omarchy.system-update refresh + else + omarchy-shell -q omarchy.system-update clear + fi + + omarchy-update-restart +} + +acquire_update_lock + +trap restore_update_inhibitors EXIT trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-snapshot create || (($? == 127)) - omarchy-update-perform + run_update_pipeline fi diff --git a/bin/omarchy-update-available b/bin/omarchy-update-available index 5aebfa0a..6755d349 100755 --- a/bin/omarchy-update-available +++ b/bin/omarchy-update-available @@ -1,15 +1,119 @@ #!/bin/bash -# omarchy:summary=Check whether the Omarchy package has an available update. +# omarchy:summary=Check whether system or AUR packages have available updates. -set -e +set -euo pipefail -if pacman -Qu omarchy >/tmp/omarchy-update-available.$$ 2>/dev/null; then - cat /tmp/omarchy-update-available.$$ - rm -f /tmp/omarchy-update-available.$$ +CHECK_TIMEOUT="${OMARCHY_UPDATE_CHECK_TIMEOUT:-60}" +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/updates" +TMP_PARENT="${TMPDIR:-/tmp}" + +mkdir -p "$STATE_DIR" + +tmp_dir=$(mktemp -d "$TMP_PARENT/omarchy-update-available.XXXXXX") +trap 'rm -rf "$tmp_dir"' EXIT + +packages_raw="$tmp_dir/packages.raw" +packages_tmp="$tmp_dir/packages" +aur_raw="$tmp_dir/aur.raw" +aur_tmp="$tmp_dir/aur" +errors_tmp="$tmp_dir/errors" + +packages_file="$STATE_DIR/packages" +aur_file="$STATE_DIR/aur" +available_file="$STATE_DIR/available" +checked_at_file="$STATE_DIR/checked-at" +error_file="$STATE_DIR/error" + +: >"$packages_raw" +: >"$packages_tmp" +: >"$aur_raw" +: >"$aur_tmp" +: >"$errors_tmp" + +strip_update_output() { + local source="$1" + local destination="$2" + + sed -E 's/\x1B\[[0-9;]*m//g' "$source" | sed '/^[[:space:]]*$/d' >"$destination" +} + +check_system_packages() { + local status=0 + + if command -v checkupdates >/dev/null 2>&1 && command -v fakeroot >/dev/null 2>&1; then + CHECKUPDATES_DB="$tmp_dir/checkupdates-db" timeout "$CHECK_TIMEOUT" checkupdates --nocolor >"$packages_raw" 2>"$tmp_dir/checkupdates.err" || status=$? + + case "$status" in + 0|2) + ;; + 124) + echo "System package update check timed out" >>"$errors_tmp" + ;; + *) + echo "System package update check failed" >>"$errors_tmp" + sed '/^[[:space:]]*$/d' "$tmp_dir/checkupdates.err" >>"$errors_tmp" || true + ;; + esac + elif command -v pacman >/dev/null 2>&1; then + # Fallback for systems that have not picked up pacman-contrib/fakeroot yet. + # This only reports updates already known to the local sync database. + pacman -Qu --color never >"$packages_raw" 2>/dev/null || true + else + echo "pacman is not available" >>"$errors_tmp" + fi + + strip_update_output "$packages_raw" "$packages_tmp" +} + +check_aur_packages() { + local status=0 + + command -v pacman >/dev/null 2>&1 || return 0 + command -v yay >/dev/null 2>&1 || return 0 + pacman -Qem >/dev/null 2>&1 || return 0 + + timeout "$CHECK_TIMEOUT" yay --color never -Qua >"$aur_raw" 2>"$tmp_dir/yay.err" || status=$? + + case "$status" in + 0|1) + ;; + 124) + echo "AUR package update check timed out" >>"$errors_tmp" + ;; + *) + echo "AUR package update check failed" >>"$errors_tmp" + sed '/^[[:space:]]*$/d' "$tmp_dir/yay.err" >>"$errors_tmp" || true + ;; + esac + + strip_update_output "$aur_raw" "$aur_tmp" + grep -vw '\[ignored\]$' "$aur_tmp" >"$aur_tmp.filtered" || true + mv "$aur_tmp.filtered" "$aur_tmp" +} + +check_system_packages +check_aur_packages + +cp "$packages_tmp" "$packages_file" +cp "$aur_tmp" "$aur_file" +cat "$packages_tmp" "$aur_tmp" >"$available_file" +date --iso-8601=seconds >"$checked_at_file" + +if [[ -s $errors_tmp ]]; then + cp "$errors_tmp" "$error_file" +else + rm -f "$error_file" +fi + +if [[ -s $available_file ]]; then + cat "$available_file" exit 0 fi -rm -f /tmp/omarchy-update-available.$$ -echo "Omarchy is up to date" +if [[ -s $error_file ]]; then + cat "$error_file" >&2 +fi + +echo "System is up to date" exit 1 diff --git a/bin/omarchy-update-available-reset b/bin/omarchy-update-available-reset deleted file mode 100755 index d2817fff..00000000 --- a/bin/omarchy-update-available-reset +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Ensure the status bar icon offering the available update is removed - -omarchy-shell -q SystemUpdate refresh -exit 0 diff --git a/bin/omarchy-update-orphan-pkgs b/bin/omarchy-update-orphan-pkgs index b1486867..7869671a 100755 --- a/bin/omarchy-update-orphan-pkgs +++ b/bin/omarchy-update-orphan-pkgs @@ -1,13 +1,29 @@ #!/bin/bash -# omarchy:summary=Remove orphaned system packages after updates +# omarchy:summary=Review and optionally remove orphaned system packages after updates # omarchy:requires-sudo=true -orphans=$(pacman -Qtdq || true) -if [[ -n $orphans ]]; then - echo -e "\e[32m\nRemove orphan system packages\e[0m" - for pkg in $orphans; do - sudo pacman -Rs --noconfirm "$pkg" || true - done +set -e + +mapfile -t orphans < <(pacman -Qtdq 2>/dev/null || true) +(( ${#orphans[@]} )) || exit 0 + +echo -e "\e[32m\nOrphan system packages\e[0m" +printf ' %s\n' "${orphans[@]}" +echo + +if [[ ! -t 0 || ! -t 1 ]]; then + echo "${#orphans[@]} orphaned package(s) found. Re-run omarchy-update-orphan-pkgs in a terminal to review/remove them." echo + exit 0 fi + +if ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then + echo "Keeping orphaned packages." + echo + exit 0 +fi + +echo -e "\e[32m\nRemoving orphan system packages\e[0m" +sudo pacman -Rns "${orphans[@]}" +echo diff --git a/bin/omarchy-update-pacman-guard b/bin/omarchy-update-pacman-guard new file mode 100755 index 00000000..616702a8 --- /dev/null +++ b/bin/omarchy-update-pacman-guard @@ -0,0 +1,64 @@ +#!/bin/bash + +# omarchy:summary=Prevent direct pacman system upgrades from bypassing omarchy update. +# omarchy:hidden=true + +set -euo pipefail + +if [[ ${OMARCHY_UPDATE_PACMAN:-} == "1" || ${OMARCHY_ALLOW_DIRECT_PACMAN:-} == "1" ]]; then + exit 0 +fi + +pacman_args=() +if [[ -n ${OMARCHY_PACMAN_CMDLINE:-} ]]; then + read -r -a pacman_args <<<"$OMARCHY_PACMAN_CMDLINE" +elif [[ -r /proc/$PPID/cmdline ]]; then + mapfile -d '' -t pacman_args <"/proc/$PPID/cmdline" || true +fi + +is_system_upgrade=0 +has_sync=0 +has_sysupgrade=0 + +for arg in "${pacman_args[@]}"; do + case "$arg" in + --sync) + has_sync=1 + ;; + --sysupgrade) + has_sysupgrade=1 + ;; + --*) + ;; + -*) + [[ $arg == *S* ]] && has_sync=1 + [[ $arg == *u* ]] && has_sysupgrade=1 + ;; + esac +done + +if (( has_sync && has_sysupgrade )); then + is_system_upgrade=1 +fi + +(( is_system_upgrade )) || exit 0 + +cat >&2 <<'MESSAGE' + +Woah partner... + +This looks like a direct pacman system upgrade. Omarchy updates should normally +run through: + + omarchy update + +That path handles the update transcript, snapshot, keyrings, package upgrade, +migrations, post-update hooks, shell update state, and restart checks together. + +If you really meant to bypass Omarchy for this transaction, rerun pacman with: + + sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman -Syu + +MESSAGE + +exit 1 diff --git a/bin/omarchy-update-perform b/bin/omarchy-update-perform index 8958acba..3520a138 100755 --- a/bin/omarchy-update-perform +++ b/bin/omarchy-update-perform @@ -1,26 +1,7 @@ #!/bin/bash -# omarchy:summary=Run the full Omarchy update pipeline +# omarchy:summary=Compatibility wrapper for omarchy-update -y. +# omarchy:hidden=true # omarchy:requires-sudo=true -set -e - -# Ensure screensaver/sleep doesn't set in during updates -hyprctl dispatch 'hl.dsp.window.tag({ tag = "+noidle" })' &>/dev/null || hyprctl dispatch tagwindow +noidle &>/dev/null || true - -# Perform all update steps -omarchy-update-keyring -omarchy-update-available-reset -omarchy-update-system-pkgs -omarchy-migrate -omarchy-update-aur-pkgs -omarchy-update-mise -omarchy-update-orphan-pkgs -omarchy-hook post-update - -omarchy-update-analyze-logs - -omarchy-update-restart - -# Re-enable screensaver/sleep after updates -hyprctl dispatch 'hl.dsp.window.tag({ tag = "-noidle" })' &>/dev/null || hyprctl dispatch tagwindow -- -noidle &>/dev/null || true +exec omarchy-update -y diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index cb4f24cb..f5aad0ad 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -10,7 +10,7 @@ echo -e "\e[32m\nUpdate system packages\e[0m" # Transition --overwrite: previous script-installed Omarchy wrote these paths # as unowned files; pacman would refuse the omarchy-settings upgrade on first # encounter. Drop each entry once the transition release is the baseline. -sudo pacman -Syyu --noconfirm \ +sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ --overwrite '/etc/docker/daemon.json' \ --overwrite '/etc/gnupg/dirmngr.conf' \ --overwrite '/etc/modprobe.d/omarchy-usb-autosuspend.conf' \ diff --git a/bin/omarchy-update-user-notify b/bin/omarchy-update-user-notify new file mode 100755 index 00000000..95320ff0 --- /dev/null +++ b/bin/omarchy-update-user-notify @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:summary=Compatibility wrapper for omarchy-migrate-notify. +# omarchy:hidden=true + +exec omarchy-migrate-notify "$@" diff --git a/bin/omarchy-update-without-idle b/bin/omarchy-update-without-idle deleted file mode 100755 index bea1400b..00000000 --- a/bin/omarchy-update-without-idle +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -# omarchy:summary=No-op now that omarchy-update-perform is responsible for idle management. -# omarchy:hidden=true diff --git a/default/libalpm/hooks/00-omarchy-update-guard.hook b/default/libalpm/hooks/00-omarchy-update-guard.hook new file mode 100644 index 00000000..e6a250b5 --- /dev/null +++ b/default/libalpm/hooks/00-omarchy-update-guard.hook @@ -0,0 +1,11 @@ +[Trigger] +Operation = Upgrade +Type = Package +Target = * + +[Action] +Description = Checking Omarchy update entrypoint... +When = PreTransaction +Depends = omarchy +Exec = /usr/bin/omarchy-update-pacman-guard +AbortOnFail diff --git a/default/systemd/user/omarchy-update-user-notify.path b/default/systemd/user/omarchy-update-user-notify.path new file mode 100644 index 00000000..2eb100eb --- /dev/null +++ b/default/systemd/user/omarchy-update-user-notify.path @@ -0,0 +1,10 @@ +[Unit] +Description=Watch for Omarchy user migrations + +[Path] +PathExistsGlob=/usr/share/omarchy/migrations/user/*.sh +PathModified=/usr/share/omarchy/migrations/user +Unit=omarchy-update-user-notify.service + +[Install] +WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.service b/default/systemd/user/omarchy-update-user-notify.service new file mode 100644 index 00000000..ae4c98b8 --- /dev/null +++ b/default/systemd/user/omarchy-update-user-notify.service @@ -0,0 +1,7 @@ +[Unit] +Description=Notify about pending Omarchy user migrations +ConditionPathExistsGlob=/usr/share/omarchy/migrations/user/*.sh + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-migrate-notify diff --git a/docs/file-layout.md b/docs/file-layout.md index 9bb09767..c989a208 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -54,12 +54,16 @@ bin/omarchy-debug, bin/omarchy-debug-idle, bin/omarchy-upload-log ──► omarchy-settings /usr/bin/ (needed before omarchy is installed) +default/libalpm/hooks/00-omarchy-update-guard.hook + ──► omarchy /usr/share/libalpm/hooks/00-omarchy-update-guard.hook + install/** ──► omarchy /usr/share/omarchy/install/ -migrations/** ──► omarchy /usr/share/omarchy/migrations/ +migrations/system/** ──► omarchy /usr/share/omarchy/migrations/system/ +migrations/user/** ──► omarchy /usr/share/omarchy/migrations/user/ themes/** ──► omarchy /usr/share/omarchy/themes/ shell/** ──► omarchy /usr/share/omarchy/shell/ version ──► omarchy /usr/share/omarchy/version - + /etc/skel/.local/state/omarchy/migrations/* + + /etc/skel/.local/state/omarchy/migrations/user/* config/** ──► omarchy-settings /etc/skel/.config/** (seeds new users) /usr/share/omarchy/config/** (resync source) @@ -95,7 +99,7 @@ default/** ──► omarchy-settings /usr/share/omarchy │ + symlink /etc/fonts/conf.d/30-omarchy.conf ├─ xdg-terminal-exec/*.list /usr/share/xdg-terminal-exec/ ├─ applications/mimeapps.list /usr/share/applications/mimeapps.list - ├─ systemd/user/*.service /usr/lib/systemd/user/ + ├─ systemd/user/*.{service,path} /usr/lib/systemd/user/ ├─ systemd/system-sleep/unmount-fuse /usr/lib/systemd/system-sleep/ ├─ fonts/omarchy/omarchy.ttf /usr/share/fonts/omarchy/ ├─ sddm/omarchy/ /usr/share/sddm/themes/omarchy/ @@ -164,7 +168,7 @@ It only does the things `/etc/skel` can't: - `omarchy-refresh-applications` (composes generated `.desktop` launchers). - Sources `install/user/all.sh` — theme, git, mise, keyring, per-user hardware quirks (asus mic/mixer, framework f13 audio, …). -- On `--first-install`, marks every shipped migration as already applied +- On `--first-install`, marks every shipped user migration as already applied for the freshly-created user. Idempotency marker: `~/.local/state/omarchy/finalize-user.done`. @@ -173,6 +177,43 @@ The ISO calls it as `omarchy-finalize-user --force --first-install` in the target chroot as the install user, after `omarchy-setup-system` has finished the root-side work. +## Migrations (`omarchy-migrate`) + +See [`migrations.md`](migrations.md) for the full migration model, authoring +guidelines, and troubleshooting notes. + +Omarchy migrations are split by scope: + +- `migrations/system/*.sh` — root, noninteractive, safe to run from pacman. + The `omarchy` package `post_upgrade()` runs `omarchy-migrate-system` after an + `omarchy` package upgrade, so even explicit direct-pacman bypasses still get + system migrations applied. Completion state lives in + `/var/lib/omarchy/migrations/system/`. +- `migrations/user/*.sh` — current user/session, may be interactive, and may + touch `~/.config`, `~/.local`, user systemd, DBus, browser prefs, etc. + Completion state lives in `~/.local/state/omarchy/migrations/user/`. + +Package upgrades happen as root, but user migrations must run as each user and +may be interactive. There is no root-owned "user migration required" marker. A +user migration is pending only when a script exists in `migrations/user/` and +that user's matching state file is missing. + +Each graphical user has `omarchy-update-user-notify.path` watching the packaged +user migration directory. When that directory changes, or when the path unit is +started on login, `omarchy-update-user-notify.service` runs +`omarchy-migrate-notify` as that user. The notifier checks +`omarchy-migrate --pending user`. If this user has missing migration state, it +shows a notification that opens a terminal for `omarchy-migrate`. The notifier +never runs user migrations in the background. + +`omarchy-migrate` waits for any active pacman transaction to finish, runs +pending system migrations, then runs pending user migrations. It does not need +`--force`; migrations happen when state files are missing. For watchers and +diagnostics, `omarchy-migrate --pending [all|system|user]` prints +scope-prefixed pending migration filenames and exits `0` when any are pending. +`omarchy update` runs `omarchy-migrate` after the package transaction in the +already-visible update terminal, then runs `omarchy-hook post-update`. + ## First-run (`omarchy-first-run`) Runs once on first interactive login, after the user manager is live. Used @@ -183,9 +224,9 @@ systemd instance: Voxtype post-update hook. - `install/user/first-run/enable-user-units.sh` — `systemctl --user enable` the shipped user units (`bt-agent`, `omarchy-sleep-lock`, - `omarchy-recover-internal-monitor`). Done here, not at finalize, because + `omarchy-recover-internal-monitor`, `omarchy-update-user-notify.path`). Done here, not at finalize, because the user manager isn't reachable from the ISO chroot; `ConditionPath*` - in the unit files keeps them inert on hardware they don't apply to. + in the unit files keeps services inert when they don't apply. - `install/user/first-run/gnome-theme.sh`, `install/user/first-run/gtk-primary-paste.sh` — GNOME/GTK settings that need the dconf daemon. @@ -236,10 +277,11 @@ without backup. | Default file at `~/.config/foo/` | `config/foo/` | | `/etc/` drop-in we own outright | `etc/` | | `/etc/` file owned by an upstream package | `default/`, then add to `etc-overrides` in `omarchy-settings` PKGBUILD + scriptlet | -| Package-owned system file (e.g. systemd user service in `/usr/lib`) | `default/`, document the mapping in `default/package-defaults.tsv`, then add the `install -Dm644` line in `omarchy-settings` PKGBUILD | +| Package-owned system file (e.g. systemd user service/path in `/usr/lib`) | `default/`, document the mapping in `default/package-defaults.tsv`, then add the `install -Dm644` line in `omarchy-settings` PKGBUILD | | Per-user file that's static but lives outside `~/.config` | `default/`, then add `install -Dm644 ... $pkgdir/etc/skel/...` in `omarchy-settings` PKGBUILD | | Runtime tweak that needs `$HOME` or live system state | extend `omarchy-finalize-user`, or add a per-user leaf under `install/user/` and wire into `install/user/all.sh` | | One-time root-side setup step | `install/config/*.sh` or `install/hardware/*.sh`, wire into `omarchy-setup-system` or `install/hardware/all.sh` | +| Noninteractive root fix for existing installs | `migrations/system/.sh` | +| User/session fix for existing installs | `migrations/user/.sh` | | User-facing `omarchy-*` command | `bin/omarchy--` — see `GROUP_DESCRIPTIONS` in `bin/omarchy` | | New theme | `themes//` (+ matching templates under `default/themed/` if they need theme colors) | -| One-shot fix for installed systems | `migrations/.sh` (use `omarchy-dev-add-migration --no-edit`) | diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 00000000..e8db4a68 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,307 @@ +# Omarchy migrations + +Omarchy migrations are one-time repair scripts for existing installs. They are +used when a package update needs to change state that pacman cannot safely own by +itself. + +They are for both humans and agents: + +- Users should know when migrations run, where state is stored, and how to retry + or inspect pending work. +- Agents should know which migration scope to use, how to write safe migration + scripts, and what should **not** be a migration. + +## The two migration scopes + +Omarchy migrations are split by execution context: + +```text +migrations/system/*.sh +migrations/user/*.sh +``` + +### System migrations + +System migrations run as root and must be noninteractive. + +Use system migrations for machine-wide state, for example: + +- `/etc` drop-ins or legacy config cleanup +- `/boot` / bootloader cleanup +- systemd system units or service state +- hardware quirks +- package-owned layout transitions + +State lives in: + +```text +/var/lib/omarchy/migrations/system/ +``` + +### User migrations + +User migrations run as the current graphical/login user and may be interactive. + +Use user migrations for per-user or session-aware state, for example: + +- `~/.config` changes +- `~/.local` state +- user systemd units +- browser/editor preferences +- DBus/session-dependent updates +- prompts that need to happen in a visible terminal + +State lives in: + +```text +~/.local/state/omarchy/migrations/user/ +``` + +A user migration is pending for a given user only when the script exists under +`/usr/share/omarchy/migrations/user/` and that user's matching state file is +missing. + +There is no global root-owned “user migrations required” queue. + +## When migrations run + +### During `omarchy update` + +`omarchy update` is the normal update path. It runs package updates, then: + +```bash +omarchy-migrate +omarchy-hook post-update +``` + +`omarchy-migrate`: + +1. waits for any active pacman transaction to finish +2. runs pending system migrations +3. runs pending user migrations for the current user + +System migrations usually already ran during the pacman transaction via the +`omarchy` package `post_upgrade()`, but the second check is intentional and +idempotent. + +### During direct pacman updates + +Raw `sudo pacman -Syu` is guarded. Users should normally run: + +```bash +omarchy update +``` + +If a user explicitly bypasses the guard, the `omarchy` package still runs system +migrations from `post_upgrade()`: + +```bash +omarchy-migrate-system +``` + +User migrations are never run by pacman. Instead, the user session watches the +packaged user migration directory and runs a notifier. The notifier checks: + +```bash +omarchy-migrate --pending user +``` + +If that user has pending user migrations, it shows a notification that opens a +terminal for: + +```bash +omarchy-migrate +``` + +The notifier never runs user migrations silently in the background. + +### Manually + +Users can safely run: + +```bash +omarchy-migrate +``` + +at any time. Already-completed migrations are skipped. + +## Inspecting pending migrations + +Use: + +```bash +omarchy-migrate --pending +omarchy-migrate --pending system +omarchy-migrate --pending user +``` + +Exit behavior: + +- `0` — one or more matching migrations are pending +- non-zero — no matching migrations are pending + +Output is scope-prefixed: + +```text +system/100-system.sh +user/200-user.sh +``` + +The lower-level runners also support `--pending` and print filenames without the +scope prefix: + +```bash +omarchy-migrate-system --pending +omarchy-migrate-user --pending +``` + +Prefer `omarchy-migrate --pending ...` in user-facing tools and watchers. + +## Writing migrations + +Create a migration with: + +```bash +omarchy-dev-add-migration system --no-edit +omarchy-dev-add-migration user --no-edit +``` + +Choose the scope based on the state being changed, not on convenience. + +### File format + +Migrations are plain shell files: + +- filename: unix timestamp, generated by `omarchy-dev-add-migration` +- mode: `0644` +- no shebang +- start with an `echo` describing the migration +- use `$OMARCHY_PATH` for Omarchy-owned files + +Example system migration: + +```bash +echo "Remove legacy Limine config" + +rm -f /boot/EFI/limine/limine.conf +``` + +Example user migration: + +```bash +echo "Refresh user app launchers" + +omarchy-refresh-applications +``` + +### Execution model + +Migration runners execute scripts with strict shell settings: + +```bash +bash -euo pipefail +``` + +`$OMARCHY_PATH` is exported into the migration process. + +A migration is marked complete only after the script exits successfully. If it +fails, the marker is not written and the migration will be retried later. + +Because failed migrations can be retried after doing partial work, migrations +must be idempotent. Prefer operations that are safe to run more than once: + +```bash +mkdir -p ~/.config/example +rm -f ~/.config/example/legacy.conf +install -Dm644 "$OMARCHY_PATH/default/example.conf" ~/.config/example/example.conf +systemctl --user enable --now some-unit.service || true +``` + +Use `|| true` only when a failure is genuinely acceptable. + +## What belongs in a migration? + +Good migration uses: + +- deleting or moving legacy files that block the packaged layout +- marking or enabling new service state +- migrating old config paths to new config paths +- repairing known bad state from an earlier Omarchy release +- one-time compatibility for existing installs + +Bad migration uses: + +- fresh-install setup that belongs in `install/` or `/etc/skel` +- package installation that belongs in package dependencies or package lists +- recurring maintenance +- arbitrary cleanup that should be a user command +- pre-4 upgrade work that belongs in `omarchy-upgrade-to-4` +- hidden user/session work from pacman + +## Fresh installs and new users + +Fresh users created from the packaged layout should not have to run historical +user migrations. The package seeds user migration markers into `/etc/skel` so +new users start with shipped user migrations marked complete. + +The ISO/finalization path also marks shipped user migrations complete for the +freshly-created install user when running: + +```bash +omarchy-finalize-user --first-install +``` + +Existing users keep their own migration state and run only migrations whose +state files are missing. + +## Troubleshooting + +### See what is pending + +```bash +omarchy-migrate --pending +``` + +### Retry failed migrations + +Fix the underlying problem, then run: + +```bash +omarchy-migrate +``` + +A failed migration is not marked complete, so it will retry. + +### Re-run a completed migration manually + +Remove its marker, then run the migration command again: + +```bash +rm ~/.local/state/omarchy/migrations/user/.sh +omarchy-migrate +``` + +For system migrations: + +```bash +sudo rm /var/lib/omarchy/migrations/system/.sh +omarchy-migrate +``` + +Be careful: migrations should be idempotent, but manually removing markers is an +advanced troubleshooting step. + +## Agent checklist + +Before adding a migration: + +1. Is this a one-time repair for existing installs? +2. Is it system state or user/session state? +3. Can it run safely more than once? +4. Will it fail loudly if the important work fails? +5. Does it avoid hidden interactive work from pacman? +6. Does it belong in `omarchy-upgrade-to-4` instead? +7. Did you add or update tests if behavior changed? + +If the answer to any of these is unclear, stop and ask before adding the +migration. diff --git a/docs/update-process.md b/docs/update-process.md new file mode 100644 index 00000000..ca6d202a --- /dev/null +++ b/docs/update-process.md @@ -0,0 +1,318 @@ +# Omarchy update process + +This document describes the intended update behavior now that Omarchy is +package-backed. It covers the blessed update path plus what happens when a user attempts to +bypass it: + +1. `omarchy update` — the blessed interactive Omarchy update flow. +2. `sudo pacman -Syu` — guarded by Omarchy and aborted with instructions unless + the user explicitly bypasses the guard. + +The design goal is: + +- System/root migrations should run automatically from package transactions when + safe. +- User/session migrations should never run invisibly from pacman because they may + need `$HOME`, DBus/session state, a graphical session, or user interaction. +- Users who bypass `omarchy update` should still get system migrations from + pacman and should be notified only when their own user migration state is + missing a shipped user migration. + +## State and coordination files + +| Path | Owner | Purpose | +| --- | --- | --- | +| `${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.lock` | user | Prevent overlapping update runs. Owned by `omarchy-update`; compatibility wrappers inherit/respect it. | +| `/tmp/omarchy-update.log` | user | Transcript of `omarchy update`, used by `omarchy-update-analyze-logs`. | +| `~/.local/state/omarchy/updates/` | user | Update-check state for the shell widget (`packages`, `aur`, `available`, `checked-at`, `error`). | +| `/var/lib/omarchy/migrations/system/` | root | System migration markers. | +| `~/.local/state/omarchy/migrations/user/` | user | User migration markers. | +| `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | +| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. | + +## Migration layout + +See [`migrations.md`](migrations.md) for the full migration model, authoring +guidelines, and troubleshooting notes. + +Migrations are scoped by directory: + +```text +migrations/system/*.sh +migrations/user/*.sh +``` + +### System migrations + +System migrations are root-owned, noninteractive fixes for existing installs. +They may touch `/etc`, `/usr`, `/boot`, system services, hardware configuration, +and other machine-wide state. + +Runner: + +```bash +omarchy-migrate-system +``` + +Completion state: + +```text +/var/lib/omarchy/migrations/system/ +``` + +Pacman integration: + +```text +pkgbuilds/omarchy/omarchy.install post_upgrade() +``` + +The `omarchy` package calls `omarchy-migrate-system` from `post_upgrade()`. +The runner is idempotent, so repeated calls only check the state files. + +### User migrations + +User migrations are current-user/session fixes. They may touch `~/.config`, +`~/.local`, user systemd units, browser/editor preferences, DBus/session state, +or ask questions. + +Runner: + +```bash +omarchy-migrate-user +``` + +Public command: + +```bash +omarchy-migrate +``` + +Completion state: + +```text +~/.local/state/omarchy/migrations/user/ +``` + +A user migration is pending only when a script exists in `migrations/user/` and +that user's matching state file is missing. There is no global root-owned queue +for user migrations. + +`omarchy-migrate` is the public command. It waits for any active pacman +transaction to finish, runs pending system migrations, then runs pending user +migrations. It does not need `--force`; migrations should happen when they are +pending. + +For watchers and diagnostics, `omarchy-migrate --pending [all|system|user]` +prints pending migration names and exits `0` when any are pending. Output is +scope-prefixed, for example: + +```text +system/100-system.sh +user/200-user.sh +``` + +When no matching migrations are pending, it prints nothing and exits non-zero. + +## Raw pacman guard + +The `omarchy` package installs an ALPM pre-transaction hook alongside its guard +binary: + +```text +/usr/share/libalpm/hooks/00-omarchy-update-guard.hook +/usr/bin/omarchy-update-pacman-guard +``` + +It triggers on package upgrades and runs: + +```bash +omarchy-update-pacman-guard +``` + +The guard detects direct pacman system-upgrade commands like `pacman -Syu` or +`pacman --sync --refresh --sysupgrade`. If the upgrade was not launched by an +Omarchy update command, the hook exits non-zero with `AbortOnFail`, which stops +the transaction before packages are changed. + +`omarchy-update-system-pkgs`, `omarchy-refresh-pacman`, `omarchy-reinstall-pkgs`, +and the v4 upgrader run pacman through: + +```bash +env OMARCHY_UPDATE_PACMAN=1 pacman ... +``` + +so the guard allows Omarchy-owned update flows. A user can intentionally bypass +the guard with: + +```bash +sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman -Syu +``` + +The guard does not start `omarchy update` itself because pacman is already in a +transaction setup path; it only aborts with instructions. + +## Path 1: `omarchy update` + +High-level flow: + +```text +omarchy-update + ├─ ensure transcript logging through script(1) → /tmp/omarchy-update.log + ├─ acquire update lock + ├─ confirm unless -y + ├─ create snapper snapshot, if snapper is installed + └─ run update pipeline + ├─ block system sleep and temporarily enable shell stay-awake mode + ├─ omarchy-update-keyring + ├─ omarchy-update-system-pkgs + ├─ omarchy-migrate + ├─ omarchy-hook post-update + ├─ omarchy-update-aur-pkgs + ├─ omarchy-update-mise + ├─ omarchy-update-orphan-pkgs + ├─ omarchy-update-analyze-logs + ├─ omarchy-update-available, then refresh/clear shell indicator + ├─ omarchy-update-restart + └─ release sleep inhibitor and restore shell idle state, if changed +``` + +Important behavior: + +- `omarchy update` checks/runs system and user migrations in the same visible + terminal via `omarchy-migrate`. +- `omarchy update` still benefits from pacman-triggered system migrations + because the system migration hook runs as part of the pacman transaction. +- A failure should leave enough output in `/tmp/omarchy-update.log` and the + terminal transcript to debug. + +## Path 2: direct `sudo pacman -Syu` attempt + +High-level flow: + +```text +sudo pacman -Syu + ├─ pre-transaction guard aborts and tells the user to run omarchy update + └─ if explicitly bypassed, upgrades omarchy and related packages + ├─ omarchy post_upgrade runs omarchy-migrate-system as root + └─ user session notices migration directory changes + ├─ omarchy-update-user-notify.path triggers, if enabled + ├─ omarchy-migrate-notify checks omarchy-migrate --pending user + ├─ if this user has missing migration state, show notification + └─ click opens terminal: omarchy-migrate +``` + +Fallbacks: + +- `omarchy-first-run` enables the user notification path unit. +- `omarchy-first-run` also invokes `omarchy-migrate-notify` on graphical + startup, so users who updated before the path unit existed still get prompted + if they have missing user migration state. +- The notifier is only a prompt. It does not run user migrations in the + background. +- Direct pacman updates do not run `omarchy-hook post-update` unless the user + explicitly runs that hook; without a package-update marker, the only user-side + pending state we can derive is missing user migration markers. + +## Shell update indicator + +The bar widget `omarchy.system-update` runs: + +```bash +omarchy-update-available +``` + +`omarchy-update-available` uses `checkupdates` with a temporary database when +available, plus `yay -Qua` for AUR updates when foreign packages are installed. +It stores the result in: + +```text +~/.local/state/omarchy/updates/packages +~/.local/state/omarchy/updates/aur +~/.local/state/omarchy/updates/available +~/.local/state/omarchy/updates/checked-at +~/.local/state/omarchy/updates/error +``` + +Exit codes: + +- `0` — updates are available; stdout is the update list. +- non-zero — no updates are available; stdout says the system is up to date. + +The widget uses the line count to show/hide itself. It also watches the +`available` state file, so manually running `omarchy-update-available` updates +the widget as soon as the file changes. Hovering the update icon opens a panel +that lists Omarchy packages first, then all other package updates. + +## Update-related binaries + +This inventory is intentionally opinionated. Some commands are useful as stable +leaf commands; others exist mostly because the old update flow accreted small +scripts. + +| Binary | Current purpose | Keep? / Question | +| --- | --- | --- | +| `omarchy-update` | Public user command. Adds transcript logging, lock, confirmation, snapshot, sleep/idle inhibitors, package updates, migrations, hooks, update-state refresh, and restart checks. | **Keep.** This is the blessed entry point and owns the update pipeline. | +| `omarchy-update-perform` | Hidden compatibility wrapper for `omarchy-update -y`. | **Temporary.** Keep only for old callers; new code should call `omarchy-update` directly. | +| `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | +| `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | +| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with targeted transition `--overwrite` entries so the ALPM guard allows the transaction and early package-layout conflicts are handled. | **Keep for now.** Small leaf command, clear/testable. | +| `omarchy-migrate-system` | Runs root/system migrations from `migrations/system`. Called by `omarchy` package `post_upgrade()` and by `omarchy-migrate` when system migration state is missing. | **Keep.** This is the important direct-`pacman -Syu` integration. | +| `omarchy-migrate-user` | Checks `migrations/user` against this user's state and runs only missing user migrations. Supports `--pending` and prints pending filenames. | **Keep internal.** Public users should generally run `omarchy-migrate`. | +| `omarchy-migrate` | Public migration command. Waits for pacman, then runs pending system and user migrations. Supports `--pending [all|system|user]` and prints scope-prefixed pending filenames. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | +| `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. | +| `omarchy-migrate-notify` | Internal notification helper for direct pacman updates. Uses `omarchy-migrate --pending user` and shows notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | +| `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. | +| `omarchy-update-available` | Update checker for shell widget and post-update refresh. Writes update state. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | +| `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. | +| `omarchy-update-mise` | Runs `mise up` for mise-managed tools. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | +| `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | +| `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | +| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates and restarts components with `restart-*-required` markers. | **Keep.** Important final step; may eventually include service-restart checks. | +| `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | +| `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | + +## Closed decisions + +1. **System migrations run from the `omarchy` package** + - `omarchy.install post_upgrade()` calls `omarchy-migrate-system`. + - We do not need a separate system-migration ALPM hook from + `omarchy-settings`. + +2. **Root vs user migrations stay separate** + - Do not use `su {user}` from pacman to run user migrations. + - User migrations need the user's real session and should be visible. + - Pending user work is determined only by comparing `migrations/user/*.sh` + against `~/.local/state/omarchy/migrations/user/`. + +3. **Migration notification naming** + - The real helper is `omarchy-migrate-notify`. + - `omarchy-update-user-notify` remains only as a hidden compatibility wrapper. + +4. **Update pipeline ownership** + - `omarchy-update` owns the full update pipeline now. + - `omarchy-update-perform` is only a hidden compatibility wrapper for + `omarchy-update -y`. + +5. **Mise remains in the blessed update path** + - `omarchy-update-mise` intentionally runs as part of `omarchy update`. + +6. **Orphan cleanup stays in the update path for now** + - It is prompt-only and never removes packages noninteractively. + +7. **Direct pacman user follow-up is based on actual migration state** + - Direct `sudo pacman -Syu` no longer uses a fake user-update marker. + - User notifications are shown only when `omarchy-migrate --pending user` + finds missing per-user migration state. + +## Remaining concerns + +1. **Pacman guard scope** + - The guard detects direct pacman sysupgrade invocations and allows Omarchy + commands that set `OMARCHY_UPDATE_PACMAN=1`. + - We may regret blocking some legitimate package-manager frontends or + maintenance flows. Keep an eye on what should be allowed versus redirected + to `omarchy update`. + +2. **Pacnew/pacsave handling is still missing** + - Package-backed Omarchy should warn about or help process `.pacnew` and + `.pacsave` files after updates. diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index b82d2067..776c09c2 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -30,6 +30,7 @@ evince exfatprogs expac eza +fakeroot fastfetch fcitx5 fcitx5-gtk @@ -91,6 +92,7 @@ nvim obs-studio obsidian omarchy-nvim +pacman-contrib pamixer pinta plocate diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 18b3a449..9e695296 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -14,4 +14,5 @@ set -euo pipefail systemctl --user enable --now \ bt-agent.service \ omarchy-recover-internal-monitor.service \ - omarchy-sleep-lock.service + omarchy-sleep-lock.service \ + omarchy-update-user-notify.path diff --git a/migrations/.gitkeep b/migrations/system/.gitkeep similarity index 100% rename from migrations/.gitkeep rename to migrations/system/.gitkeep diff --git a/migrations/user/.gitkeep b/migrations/user/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/shell/plugins/bar/widgets/SystemUpdate.qml b/shell/plugins/bar/widgets/SystemUpdate.qml index 64e8b0d6..f93c4fa8 100644 --- a/shell/plugins/bar/widgets/SystemUpdate.qml +++ b/shell/plugins/bar/widgets/SystemUpdate.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import Quickshell import Quickshell.Io import qs.Commons @@ -9,11 +10,86 @@ BarWidget { moduleName: "omarchy.system-update" property bool updateAvailable: false + property int updateCount: 0 + property string updateOutput: "" + property var updateLines: [] + property var omarchyUpdateLines: [] + property var otherUpdateLines: [] + property bool popupOpen: false + property bool buttonHovered: false + property bool popupHovered: popup.containsMouse + + readonly property string stateHome: Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state") + readonly property string availableStatePath: stateHome + "/omarchy/updates/available" + + function close() { popupOpen = false } function refresh() { + updateOutput = "" if (!updateProc.running) updateProc.running = true } + function clear() { + updateOutput = "" + updateLines = [] + omarchyUpdateLines = [] + otherUpdateLines = [] + updateCount = 0 + updateAvailable = false + popupOpen = false + } + + function packageName(line) { + return String(line || "").trim().split(/\s+/)[0] || "" + } + + function isOmarchyPackage(line) { + var pkg = packageName(line) + return pkg === "omarchy" || pkg.indexOf("omarchy-") === 0 + } + + function countLabel(count) { + return count === 1 ? "1 update" : count + " updates" + } + + function parseUpdateText(text) { + var lines = String(text || "").split(/\r?\n/).filter(function(line) { + return line.trim().length > 0 + }) + var omarchyLines = [] + var otherLines = [] + + for (var i = 0; i < lines.length; i++) { + if (isOmarchyPackage(lines[i])) omarchyLines.push(lines[i]) + else otherLines.push(lines[i]) + } + + omarchyUpdateLines = omarchyLines + otherUpdateLines = otherLines + updateLines = omarchyLines.concat(otherLines) + updateCount = updateLines.length + updateAvailable = updateCount > 0 + if (!updateAvailable) popupOpen = false + } + + function applyUpdateOutput(exitCode) { + var output = String(updateStdout.text || updateOutput || "") + parseUpdateText(exitCode === 0 ? output : "") + } + + function showPopup() { + hideTimer.stop() + if (updateAvailable) popupOpen = true + } + + function scheduleHide() { + hideTimer.restart() + } + + onButtonHoveredChanged: buttonHovered ? showPopup() : scheduleHide() + onPopupHoveredChanged: popupHovered ? hideTimer.stop() : scheduleHide() + onUpdateAvailableChanged: if (!updateAvailable) popupOpen = false + visible: updateAvailable implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight @@ -24,17 +100,31 @@ BarWidget { function refresh(): void { root.refresh() } + + function clear(): void { + root.clear() + } } Process { id: updateProc command: ["bash", "-lc", "omarchy-update-available"] - stdout: StdioCollector { waitForEnd: true } + stdout: StdioCollector { id: updateStdout; waitForEnd: true; onStreamFinished: root.updateOutput = text } onExited: function(exitCode) { - root.updateAvailable = exitCode === 0 + root.applyUpdateOutput(exitCode) } } + FileView { + id: availableState + path: root.availableStatePath + watchChanges: true + printErrors: false + onLoaded: root.parseUpdateText(text()) + onLoadFailed: root.clear() + onFileChanged: reload() + } + Timer { interval: 21600000 running: true @@ -43,13 +133,134 @@ BarWidget { onTriggered: root.refresh() } + Timer { + id: hideTimer + interval: 220 + onTriggered: { + if (!root.buttonHovered && !root.popupHovered) root.popupOpen = false + } + } + WidgetButton { id: button anchors.fill: parent bar: root.bar text: root.updateAvailable ? "\uf021" : "" fontSize: Style.font.caption - tooltipText: root.updateAvailable ? "Omarchy update available" : "" + tooltipText: "" onPressed: function() { root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-update") } } + + HoverHandler { + id: hoverHandler + target: button + onHoveredChanged: root.buttonHovered = hovered + } + + PopupCard { + id: popup + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen && root.updateAvailable + triggerMode: "hover" + contentWidth: popup.fittedContentWidth(Style.space(460)) + contentHeight: popup.fittedContentHeight(panelColumn.implicitHeight, Style.space(520)) + + Flickable { + id: updateFlick + anchors.fill: parent + contentWidth: width + contentHeight: panelColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: panelColumn + width: updateFlick.width + spacing: Style.space(12) + + Row { + width: parent.width + spacing: Style.space(8) + + Text { + text: "\uf021" + color: root.bar ? root.bar.urgent : Color.urgent + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.title + verticalAlignment: Text.AlignVCenter + } + + Column { + width: parent.width - Style.space(28) + spacing: Style.space(2) + + Text { + width: parent.width + text: countLabel(root.updateCount) + " available" + color: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.body + font.bold: true + } + + Text { + width: parent.width + text: "Click to run omarchy update" + color: Qt.darker(Color.popups.text, 1.35) + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.bodySmall + } + } + } + + UpdateSection { + title: "Omarchy" + lines: root.omarchyUpdateLines + width: parent.width + } + + UpdateSection { + title: "Other packages" + lines: root.otherUpdateLines + width: parent.width + } + } + } + } + + component UpdateSection: Column { + id: section + + property string title: "" + property var lines: [] + + visible: lines.length > 0 + spacing: Style.space(6) + + Text { + width: section.width + text: section.title + color: root.bar ? root.bar.foreground : Color.foreground + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.bodySmall + font.bold: true + } + + Repeater { + model: section.lines + + delegate: Text { + width: section.width + text: modelData + color: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WrapAnywhere + } + } + } } diff --git a/test/cli b/test/cli index 7213e2b3..b9564111 100755 --- a/test/cli +++ b/test/cli @@ -177,7 +177,10 @@ assert_output_contains "theme set help names binary" "$output" "omarchy-theme-se output=$(timeout 5 "$CLI" update --help) assert_output_contains "mutating command help does not execute target" "$output" "omarchy-update" -assert_output_contains "root command help shows related child commands" "$output" "omarchy update perform" +if [[ $output == *"omarchy update perform"* ]]; then + fail "hidden update perform compatibility wrapper is not shown" +fi +pass "hidden update perform compatibility wrapper is not shown" output=$("$CLI" screenshot --help) assert_output_contains "root alias resolves to command help" "$output" "omarchy-capture-screenshot" diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 37a239d3..c4873a6f 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -94,6 +94,7 @@ from pathlib import Path root = Path(os.environ["ROOT"]) pkgbuild = (root.parent / "omarchy-pkgs/pkgbuilds/omarchy-settings/PKGBUILD").read_text() +omarchy_pkgbuild = (root.parent / "omarchy-pkgs/pkgbuilds/omarchy/PKGBUILD").read_text() errors = [] package_defaults = [ ("default/uwsm/env.d/10-omarchy", "/usr/share/uwsm/env.d/10-omarchy", "uwsm/env"), @@ -105,6 +106,8 @@ package_defaults = [ ("default/systemd/user/bt-agent.service", "/usr/lib/systemd/user/bt-agent.service", "systemd/user/bt-agent.service"), ("default/systemd/user/omarchy-sleep-lock.service", "/usr/lib/systemd/user/omarchy-sleep-lock.service", "systemd/user/omarchy-sleep-lock.service"), ("default/systemd/user/omarchy-recover-internal-monitor.service", "/usr/lib/systemd/user/omarchy-recover-internal-monitor.service", "systemd/user/omarchy-recover-internal-monitor.service"), + ("default/systemd/user/omarchy-update-user-notify.service", "/usr/lib/systemd/user/omarchy-update-user-notify.service", "systemd/user/omarchy-update-user-notify.service"), + ("default/systemd/user/omarchy-update-user-notify.path", "/usr/lib/systemd/user/omarchy-update-user-notify.path", "systemd/user/omarchy-update-user-notify.path"), ("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"), ] @@ -116,6 +119,13 @@ for source, destination, legacy in package_defaults: if destination and (source not in pkgbuild or destination not in pkgbuild): errors.append(f"PKGBUILD does not explicitly install {source} -> {destination}") +guard_hook = "default/libalpm/hooks/00-omarchy-update-guard.hook" +guard_destination = "/usr/share/libalpm/hooks/00-omarchy-update-guard.hook" +if not (root / guard_hook).exists(): + errors.append(f"missing package default source: {guard_hook}") +if guard_hook not in omarchy_pkgbuild or guard_destination not in omarchy_pkgbuild: + errors.append(f"omarchy PKGBUILD does not install {guard_hook} -> {guard_destination}") + if errors: print("\n".join(errors), file=sys.stderr) sys.exit(1) diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh new file mode 100644 index 00000000..87b21ce8 --- /dev/null +++ b/test/shell.d/migrate-notify-test.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +mkdir -p "$stub_bin" "$test_home" + +cat >"$stub_bin/omarchy-migrate-user" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--pending" && ${OMARCHY_TEST_PENDING_MIGRATIONS:-0} == 1 ]]; then + echo 200-user.sh + exit 0 +else + exit 1 +fi +SH +chmod +x "$stub_bin/omarchy-migrate-user" + +cat >"$stub_bin/systemd-run" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$stub_bin/systemd-run" + +run_notify() { + HOME="$test_home" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + OMARCHY_TEST_PENDING_MIGRATIONS="$1" \ + "$ROOT/bin/omarchy-migrate-notify" +} + +run_notify 0 >"$test_tmp/not-pending.out" 2>"$test_tmp/not-pending.err" +[[ ! -s $test_tmp/not-pending.out ]] || fail "migration notifier stays quiet on stdout without pending migrations" +[[ ! -s $test_tmp/not-pending.err ]] || fail "migration notifier stays quiet on stderr without pending migrations" +pass "migration notifier ignores users with no pending migrations" + +run_notify 1 >"$test_tmp/pending.out" 2>"$test_tmp/pending.err" +grep -q 'Omarchy has pending user migrations' "$test_tmp/pending.err" || fail "migration notifier explains pending migrations without notification system" +grep -q 'user/200-user.sh' "$test_tmp/pending.err" || fail "migration notifier lists pending migration names" +pass "migration notifier reports pending user migrations" diff --git a/test/shell.d/migrate-scope-test.sh b/test/shell.d/migrate-scope-test.sh new file mode 100644 index 00000000..533f638f --- /dev/null +++ b/test/shell.d/migrate-scope-test.sh @@ -0,0 +1,115 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +test_root="$test_tmp/omarchy" +test_home="$test_tmp/home" +system_state="$test_tmp/system-state" +mkdir -p "$test_root/migrations/system" "$test_root/migrations/user" "$test_home" "$system_state" + +cat >"$test_root/migrations/system/100-system.sh" <<'SH' +[[ $OMARCHY_PATH == "$TEST_EXPECTED_OMARCHY_PATH" ]] +echo system >>"$TEST_CALLS" +SH +cat >"$test_root/migrations/user/200-user.sh" <<'SH' +[[ $OMARCHY_PATH == "$TEST_EXPECTED_OMARCHY_PATH" ]] +echo user >>"$TEST_CALLS" +SH + +calls="$test_tmp/calls" + +if ! HOME="$test_home" OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-migrate-user" --pending >"$test_tmp/user-pending.out"; then + fail "user migration runner reports pending migrations before state exists" +fi +grep -q '^200-user\.sh$' "$test_tmp/user-pending.out" || fail "user migration runner lists pending migration filename" +pass "user migration runner detects pending migrations" + +HOME="$test_home" \ +OMARCHY_PATH="$test_root" \ +TEST_EXPECTED_OMARCHY_PATH="$test_root" \ +TEST_CALLS="$calls" \ + "$ROOT/bin/omarchy-migrate-user" >"$test_tmp/user-first.out" +[[ $(grep -c '^user$' "$calls") -eq 1 ]] || fail "user migration runner runs user migrations" +! grep -q '^system$' "$calls" || fail "user migration runner does not run system migrations" +[[ -f $test_home/.local/state/omarchy/migrations/user/200-user.sh ]] || fail "user migration runner records user migration marker" +pass "user migration runner only runs user migrations" + +HOME="$test_home" \ +OMARCHY_PATH="$test_root" \ +TEST_EXPECTED_OMARCHY_PATH="$test_root" \ +TEST_CALLS="$calls" \ + "$ROOT/bin/omarchy-migrate-user" >"$test_tmp/user-second.out" +[[ $(grep -c '^user$' "$calls") -eq 1 ]] || fail "user migration runner skips completed migrations" +pass "user migration runner skips completed migrations" + +if HOME="$test_home" OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-migrate-user" --pending >"$test_tmp/user-not-pending.out"; then + fail "user migration runner reports no pending migrations after state exists" +fi +pass "user migration runner detects no pending migrations" + +if ! OMARCHY_PATH="$test_root" OMARCHY_SYSTEM_MIGRATION_STATE="$system_state" "$ROOT/bin/omarchy-migrate-system" --pending >"$test_tmp/system-pending.out"; then + fail "system migration runner reports pending migrations before state exists" +fi +grep -q '^100-system\.sh$' "$test_tmp/system-pending.out" || fail "system migration runner lists pending migration filename" + +OMARCHY_PATH="$test_root" \ +OMARCHY_SYSTEM_MIGRATION_STATE="$system_state" \ +TEST_EXPECTED_OMARCHY_PATH="$test_root" \ +TEST_CALLS="$calls" \ + "$ROOT/bin/omarchy-migrate-system" >"$test_tmp/system-first.out" +[[ $(grep -c '^system$' "$calls") -eq 1 ]] || fail "system migration runner runs system migrations" +[[ -f $system_state/100-system.sh ]] || fail "system migration runner records system migration marker" +pass "system migration runner only runs system migrations" + +if OMARCHY_PATH="$test_root" OMARCHY_SYSTEM_MIGRATION_STATE="$system_state" "$ROOT/bin/omarchy-migrate-system" --pending >"$test_tmp/system-not-pending.out"; then + fail "system migration runner reports no pending migrations after state exists" +fi +pass "system migration runner detects no pending migrations" + +failure_root="$test_tmp/failure-omarchy" +failure_home="$test_tmp/failure-home" +failure_system_state="$test_tmp/failure-system-state" +mkdir -p "$failure_root/migrations/system" "$failure_root/migrations/user" "$failure_home" "$failure_system_state" + +cat >"$failure_root/migrations/user/300-fail-user.sh" <<'SH' +echo user-before-fail >>"$TEST_CALLS" +false +echo user-after-fail >>"$TEST_CALLS" +SH + +set +e +HOME="$failure_home" \ +OMARCHY_PATH="$failure_root" \ +TEST_CALLS="$calls" \ + "$ROOT/bin/omarchy-migrate-user" >"$test_tmp/user-failure.out" 2>"$test_tmp/user-failure.err" +user_failure_status=$? +set -e +[[ $user_failure_status -ne 0 ]] || fail "user migration runner exits non-zero when a migration fails" +[[ ! -f $failure_home/.local/state/omarchy/migrations/user/300-fail-user.sh ]] || fail "user migration runner does not mark failed migration complete" +grep -q '^user-before-fail$' "$calls" || fail "user migration runner started failing migration" +! grep -q '^user-after-fail$' "$calls" || fail "user migration runner stops failing migration under strict mode" +pass "user migration runner does not mark failed migrations complete" + +cat >"$failure_root/migrations/system/400-fail-system.sh" <<'SH' +echo system-before-fail >>"$TEST_CALLS" +false +echo system-after-fail >>"$TEST_CALLS" +SH + +set +e +OMARCHY_PATH="$failure_root" \ +OMARCHY_SYSTEM_MIGRATION_STATE="$failure_system_state" \ +TEST_CALLS="$calls" \ + "$ROOT/bin/omarchy-migrate-system" >"$test_tmp/system-failure.out" 2>"$test_tmp/system-failure.err" +system_failure_status=$? +set -e +[[ $system_failure_status -ne 0 ]] || fail "system migration runner exits non-zero when a migration fails" +[[ ! -f $failure_system_state/400-fail-system.sh ]] || fail "system migration runner does not mark failed migration complete" +grep -q '^system-before-fail$' "$calls" || fail "system migration runner started failing migration" +! grep -q '^system-after-fail$' "$calls" || fail "system migration runner stops failing migration under strict mode" +pass "system migration runner does not mark failed migrations complete" diff --git a/test/shell.d/migrate-wrapper-test.sh b/test/shell.d/migrate-wrapper-test.sh new file mode 100644 index 00000000..1c670105 --- /dev/null +++ b/test/shell.d/migrate-wrapper-test.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +mkdir -p "$stub_bin" "$test_home" + +cat >"$stub_bin/omarchy-migrate-system" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--pending" ]]; then + [[ ${OMARCHY_TEST_PENDING_SYSTEM:-1} == 1 ]] || exit 1 + echo 100-system.sh + exit 0 +fi +echo system >>"$TEST_CALLS" +SH +chmod +x "$stub_bin/omarchy-migrate-system" + +cat >"$stub_bin/omarchy-migrate-user" <<'SH' +#!/bin/bash +if [[ ${1:-} == "--pending" ]]; then + [[ ${OMARCHY_TEST_PENDING_USER:-1} == 1 ]] || exit 1 + echo 200-user.sh + exit 0 +fi +echo user >>"$TEST_CALLS" +SH +chmod +x "$stub_bin/omarchy-migrate-user" + +run_migrate() { + HOME="$test_home" \ + PATH="$stub_bin:$PATH" \ + TEST_CALLS="$test_tmp/calls" \ + "$ROOT/bin/omarchy-migrate" "$@" +} + +: >"$test_tmp/calls" +run_migrate >"$test_tmp/migrate.out" +[[ $(sed -n '1p' "$test_tmp/calls") == "system" ]] || fail "omarchy-migrate runs system migrations first" +[[ $(sed -n '2p' "$test_tmp/calls") == "user" ]] || fail "omarchy-migrate runs user migrations second" +[[ $(wc -l <"$test_tmp/calls") -eq 2 ]] || fail "omarchy-migrate only delegates to system and user migration runners" +pass "omarchy-migrate runs system and user migrations without force" + +run_migrate --pending >"$test_tmp/pending-all.out" +grep -q '^system/100-system\.sh$' "$test_tmp/pending-all.out" || fail "omarchy-migrate --pending lists pending system migrations" +grep -q '^user/200-user\.sh$' "$test_tmp/pending-all.out" || fail "omarchy-migrate --pending lists pending user migrations" +pass "omarchy-migrate --pending lists all pending migrations" + +run_migrate --pending user >"$test_tmp/pending-user.out" +grep -q '^user/200-user\.sh$' "$test_tmp/pending-user.out" || fail "omarchy-migrate --pending user lists user migrations" +! grep -q '^system/' "$test_tmp/pending-user.out" || fail "omarchy-migrate --pending user omits system migrations" +pass "omarchy-migrate --pending user scopes pending output" + +if OMARCHY_TEST_PENDING_SYSTEM=0 OMARCHY_TEST_PENDING_USER=0 run_migrate --pending >"$test_tmp/not-pending.out"; then + fail "omarchy-migrate --pending exits non-zero without pending migrations" +fi +[[ ! -s $test_tmp/not-pending.out ]] || fail "omarchy-migrate --pending stays quiet without pending migrations" +pass "omarchy-migrate --pending reports no pending migrations" + +if run_migrate --force >"$test_tmp/force.out" 2>&1; then + fail "omarchy-migrate rejects obsolete --force option" +fi +grep -q 'Unknown option: --force' "$test_tmp/force.out" || fail "omarchy-migrate reports obsolete --force option" +pass "omarchy-migrate no longer needs --force" diff --git a/test/shell.d/update-available-test.sh b/test/shell.d/update-available-test.sh new file mode 100644 index 00000000..13604e00 --- /dev/null +++ b/test/shell.d/update-available-test.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +state_home="$test_tmp/state" +mkdir -p "$stub_bin" "$test_home" "$state_home" + +cat >"$stub_bin/fakeroot" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stub_bin/fakeroot" + +cat >"$stub_bin/checkupdates" <<'SH' +#!/bin/bash +case "${TEST_CHECKUPDATES:-updates}" in + updates) + printf 'linux 6.1-1 -> 6.1-2\nomarchy 4.0.0-1 -> 4.0.1-1\n' + exit 0 + ;; + none) + exit 2 + ;; + fail) + echo "check failed" >&2 + exit 1 + ;; +esac +SH +chmod +x "$stub_bin/checkupdates" + +cat >"$stub_bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Qem) + [[ ${TEST_AUR_INSTALLED:-0} == "1" ]] && exit 0 || exit 1 + ;; + -Qu) + if [[ ${TEST_PACMAN_FALLBACK_UPDATES:-0} == "1" ]]; then + echo "fallback-pkg 1-1 -> 1-2" + fi + exit 0 + ;; +esac +exit 0 +SH +chmod +x "$stub_bin/pacman" + +cat >"$stub_bin/yay" <<'SH' +#!/bin/bash +if [[ ${TEST_YAY_UPDATES:-0} == "1" ]]; then + echo "aur-helper 1-1 -> 1-2" +fi +exit 0 +SH +chmod +x "$stub_bin/yay" + +run_checker() { + HOME="$test_home" \ + XDG_STATE_HOME="$state_home" \ + PATH="$stub_bin:$PATH" \ + OMARCHY_UPDATE_CHECK_TIMEOUT=5 \ + "$ROOT/bin/omarchy-update-available" +} + +capture_checker() { + local stdout_file="$1" + local stderr_file="$2" + shift 2 + + set +e + ( + export "$@" + run_checker + ) >"$stdout_file" 2>"$stderr_file" + local status=$? + set -e + return "$status" +} + +stdout="$test_tmp/stdout" +stderr="$test_tmp/stderr" + +if capture_checker "$stdout" "$stderr" TEST_CHECKUPDATES=updates TEST_AUR_INSTALLED=0; then + status=0 +else + status=$? +fi +[[ $status -eq 0 ]] || fail "update checker exits successfully when system updates are available" +grep -q '^linux ' "$stdout" || fail "update checker prints system package updates" +grep -q '^omarchy ' "$state_home/omarchy/updates/packages" || fail "update checker stores system package updates" +[[ $(wc -l <"$state_home/omarchy/updates/available") -eq 2 ]] || fail "update checker stores combined update list" +pass "update checker detects system package updates" + +if capture_checker "$stdout" "$stderr" TEST_CHECKUPDATES=none TEST_AUR_INSTALLED=1 TEST_YAY_UPDATES=1; then + status=0 +else + status=$? +fi +[[ $status -eq 0 ]] || fail "update checker exits successfully when AUR updates are available" +grep -q '^aur-helper ' "$stdout" || fail "update checker prints AUR package updates" +grep -q '^aur-helper ' "$state_home/omarchy/updates/aur" || fail "update checker stores AUR package updates" +pass "update checker detects AUR package updates" + +if capture_checker "$stdout" "$stderr" TEST_CHECKUPDATES=none TEST_AUR_INSTALLED=0; then + status=0 +else + status=$? +fi +[[ $status -eq 1 ]] || fail "update checker exits non-zero when no updates are available" +grep -q '^System is up to date$' "$stdout" || fail "update checker prints up-to-date message" +[[ ! -s "$state_home/omarchy/updates/available" ]] || fail "update checker clears combined update list when up to date" +pass "update checker reports up-to-date systems" diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh new file mode 100644 index 00000000..aef0b6fe --- /dev/null +++ b/test/shell.d/update-lock-test.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +runtime_dir="$test_tmp/runtime" +mkdir -p "$stub_bin" "$test_home" "$runtime_dir" + +run_with_lock_env() { + HOME="$test_home" \ + XDG_RUNTIME_DIR="$runtime_dir" \ + XDG_STATE_HOME="$test_tmp/state" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ + "$@" +} + +write_stub() { + local name="$1" + local body="$2" + + cat >"$stub_bin/$name" <"$TEST_MARKER"; sleep 2; exit 0' + +OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 & +update_pid=$! + +for _ in {1..50}; do + [[ -f $update_snapshot_marker ]] && break + sleep 0.05 +done +[[ -f $update_snapshot_marker ]] || fail "first omarchy-update reached snapshot under lock" + +set +e +OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1 +update_second_status=$? +set -e + +wait "$update_pid" + +[[ $update_second_status -ne 0 ]] || fail "second omarchy-update exits non-zero while update lock is held" +grep -q "already running" "$test_tmp/update-second.out" || fail "second omarchy-update reports held update lock" +[[ ! -f $test_tmp/update-second-snapshot-started ]] || fail "second omarchy-update did not snapshot while lock was held" +pass "omarchy-update prevents overlapping top-level updates" + +# omarchy-update-perform is now only a compatibility wrapper around +# omarchy-update -y, but it should still respect the same update lock. +perform_marker="$test_tmp/perform-started" +write_stub omarchy-update-keyring 'echo started >"$TEST_MARKER"; sleep 2; exit 0' + +TEST_MARKER="$perform_marker" run_with_lock_env "$ROOT/bin/omarchy-update-perform" >"$test_tmp/perform-first.out" 2>&1 & +perform_pid=$! + +for _ in {1..50}; do + [[ -f $perform_marker ]] && break + sleep 0.05 +done +[[ -f $perform_marker ]] || fail "first omarchy-update-perform delegated to update under lock" + +set +e +TEST_MARKER="$test_tmp/perform-second-started" run_with_lock_env "$ROOT/bin/omarchy-update-perform" >"$test_tmp/perform-second.out" 2>&1 +perform_second_status=$? +set -e + +wait "$perform_pid" + +[[ $perform_second_status -ne 0 ]] || fail "second omarchy-update-perform exits non-zero while update lock is held" +grep -q "already running" "$test_tmp/perform-second.out" || fail "second omarchy-update-perform reports held update lock" +[[ ! -f $test_tmp/perform-second-started ]] || fail "second omarchy-update-perform did not snapshot while lock was held" +pass "omarchy-update-perform compatibility wrapper respects update lock" diff --git a/test/shell.d/update-orphan-test.sh b/test/shell.d/update-orphan-test.sh new file mode 100644 index 00000000..7d2b05cd --- /dev/null +++ b/test/shell.d/update-orphan-test.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +mkdir -p "$stub_bin" "$test_home" + +write_stub() { + local name="$1" + local body="$2" + + cat >"$stub_bin/$name" <&2; exit 99' +write_stub gum 'echo "gum should not be called" >&2; exit 99' + +run_orphan_checker >"$test_tmp/noninteractive.out" 2>"$test_tmp/noninteractive.err" +grep -q '^ old-lib$' "$test_tmp/noninteractive.out" || fail "orphan checker lists orphan packages" +grep -q 'Re-run omarchy-update-orphan-pkgs in a terminal' "$test_tmp/noninteractive.out" || fail "orphan checker does not remove packages non-interactively" +pass "orphan checker only reports orphans non-interactively" + +write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then exit 0; fi; exit 1' +run_orphan_checker >"$test_tmp/none.out" 2>"$test_tmp/none.err" +[[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist" +pass "orphan checker stays quiet without orphans" diff --git a/test/shell.d/update-pacman-guard-test.sh b/test/shell.d/update-pacman-guard-test.sh new file mode 100644 index 00000000..054cd40c --- /dev/null +++ b/test/shell.d/update-pacman-guard-test.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +run_guard() { + OMARCHY_PACMAN_CMDLINE="$1" "$ROOT/bin/omarchy-update-pacman-guard" +} + +if run_guard "pacman -Syu --noconfirm" >"$test_tmp/direct.out" 2>"$test_tmp/direct.err"; then + fail "pacman guard blocks direct system upgrades" +fi +grep -q 'omarchy update' "$test_tmp/direct.err" || fail "pacman guard explains omarchy update entrypoint" +pass "pacman guard blocks direct pacman -Syu" + +if run_guard "pacman --sync --refresh --sysupgrade" >"$test_tmp/long.out" 2>"$test_tmp/long.err"; then + fail "pacman guard blocks long-form system upgrades" +fi +pass "pacman guard blocks long-form pacman sysupgrade" + +OMARCHY_UPDATE_PACMAN=1 run_guard "pacman -Syu --noconfirm" >"$test_tmp/omarchy.out" 2>"$test_tmp/omarchy.err" +[[ ! -s $test_tmp/omarchy.err ]] || fail "pacman guard stays quiet for omarchy update pacman call" +pass "pacman guard allows omarchy update pacman call" + +OMARCHY_ALLOW_DIRECT_PACMAN=1 run_guard "pacman -Syu --noconfirm" >"$test_tmp/override.out" 2>"$test_tmp/override.err" +[[ ! -s $test_tmp/override.err ]] || fail "pacman guard stays quiet for explicit direct pacman override" +pass "pacman guard allows explicit direct pacman override" + +run_guard "pacman -S firefox" >"$test_tmp/install.out" 2>"$test_tmp/install.err" +[[ ! -s $test_tmp/install.err ]] || fail "pacman guard stays quiet for non-sysupgrade pacman command" +pass "pacman guard ignores non-system-upgrade transactions"