diff --git a/README.md b/README.md index c36d9d5..9ff94b5 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ A bar-style prompt showing current time, execution time of the last command | `get_pkgmgr` | packages | Detect the active package manager of the running distribution (`apt`, `dnf`, `yum`, `zypper`, `pacman`, `apk`, `portage`, `xbps`, `nix`) | | `pinfo` | info | Display system information read from `/proc` and `/sys` only | | `pkgf` | packages | Find which installed package owns a given file (distro-aware via `get_pkgmgr`) | +| `pkgi` | packages | Install packages with the distribution's manager (distro-aware via `get_pkgmgr`); `-y` assumes yes, `-n` only shows the command; uses `sudo` (or `doas`) when not root | +| `pkgr` | packages | Uninstall packages; `-p`/`--purge` also deletes configuration files (apt, pacman, apk; other managers just remove), `-d`/`--deps` also removes dependencies no longer needed (`--auto-remove`, `-Rs`, `--clean-deps`, `depclean`, ... — apk always does, dnf/yum are told not to unless `-d`); `-y`, `-n` as `pkgi` | | `pkgs` | packages | Search for a pattern in installed package names (distro-aware via `get_pkgmgr`) | | `ppg` | processes | Look for the given pattern in running processes | | `ppn` | processes | List processes matching an exact command name | diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index b0d0b77..de409b4 100755 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -11,6 +11,9 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). ### Added +- `pkgi` (install) and `pkgr` (uninstall, with `-p`/`--purge` and `-d`/`--deps`) + commands for apt, dnf/yum, zypper, pacman, apk, portage, xbps and nix; both + accept `-y`/`--yes` and `-n`/`--dry-run` and use `sudo` (or `doas`) when needed. - `utaz -i` / `--ignore-case` and `-s` / `--case-sensitive` switches to match archive extensions case-insensitively or not; the default is set by the new `UTAZ_DEFAULT_IGNORE_CASE` key of the `[compress]` section (default `0`). @@ -106,6 +109,8 @@ Versions follow `MAJOR.MINOR.PATCH-REVISION_STAGE_N` (e.g. `3.99.1-4_rc_1`). 403, ...) are now failures instead of saved error pages; `urlencode` mis-encoded accents under `LC_ALL` and now uses `%20` for spaces; `isipv4` leaked a variable. +- `packages.sh`: `pkgf ls` now looks the command up in the `PATH`; `pkgs` + accepts patterns starting with `-`; `suse` is recognised in `ID_LIKE`. - `compress.sh`: option flags and loop variables no longer leak into the interactive shell. diff --git a/profile.d/help.sh b/profile.d/help.sh index 8059089..50c2ab5 100644 --- a/profile.d/help.sh +++ b/profile.d/help.sh @@ -102,6 +102,8 @@ help() printf "myextip\t\tGet information about your public IP address\n" printf "pinfo\t\tDisplay system information read from /proc and /sys only\n" printf "pkgf\t\tFind which installed package owns a given file (distro-aware)\n" + printf "pkgi\t\tInstall packages (distro-aware, sudo/doas when needed)\n" + printf "pkgr\t\tUninstall packages; -p purges configuration, -d removes unneeded dependencies\n" printf "pkgs\t\tSearch for a pattern in installed package names (distro-aware)\n" printf "ppg\t\tLook for the given pattern in running processes\n" printf "ppn\t\tList processes matching an exact command name\n" diff --git a/profile.d/packages.sh b/profile.d/packages.sh index b7afed2..5faeff9 100644 --- a/profile.d/packages.sh +++ b/profile.d/packages.sh @@ -71,7 +71,7 @@ get_pkgmgr() echo "yum" return 0 ;; - opensuse*|sles|sled) + opensuse*|suse|sles|sled) echo "zypper" return 0 ;; @@ -193,7 +193,8 @@ pkgs() ;; esac - "${list_cmd[@]}" | grep ${grep_opt:+"$grep_opt"} "$pkg" + # -e: a pattern starting with "-" must not be read as a grep option + "${list_cmd[@]}" | grep ${grep_opt:+"$grep_opt"} -e "$pkg" } export -f pkgs # ------------------------------------------------------------------------------ @@ -240,6 +241,15 @@ pkgf() disp E "Please specify a file path." return 1 } + # A bare command name that is not a file here ("pkgf ls") is looked up in + # the PATH: the package managers expect a path, and would otherwise match + # the name as a pattern against every file of every package. + if [[ $file != */* && ! -e $file ]]; then + local _found + _found=$(command -v -- "$file" 2>/dev/null) && [[ $_found == /* ]] && file="$_found" + fi + # "./" keeps a name starting with "-" from being read as an option + [[ $file == -* ]] && file="./$file" local pkgmgr pkgmgr=$(get_pkgmgr) || { @@ -279,4 +289,309 @@ export -f pkgf # ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +# Internal helper: run a package manager command, with root privileges when +# needed (sudo, else doas), or only display it in dry-run mode. +# Usage: _pkg_run [args...] +_pkg_run() +{ + local needs_root="$1" dry="$2" + shift 2 + local -a prefix=() + + if (( needs_root )) && [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + prefix=(sudo) + elif command -v doas >/dev/null 2>&1; then + prefix=(doas) + elif (( ! dry )); then + disp E "Root privileges are required and neither sudo nor doas is available." + return 1 + fi + fi + + if (( dry )); then + # %q gives a copy-pasteable command line + printf '%q ' "${prefix[@]}" "$@" + printf '\n' + return 0 + fi + + if ! command -v "$1" >/dev/null 2>&1; then + disp E "'$1' is not installed." + return 1 + fi + "${prefix[@]}" "$@" +} +export -f _pkg_run +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# Install packages with the package manager of the distribution. +# Usage: pkgi [options] [package...] +pkgi() +{ + local assume_yes=0 dry_run=0 + + local PARSED + PARSED=$(getopt -o hyn --long help,yes,dry-run -n 'pkgi' -- "$@") + # shellcheck disable=SC2181 # getopt return code is checked immediately after + if [[ $? -ne 0 ]]; then + disp E "Invalid options, use \"pkgi --help\" to display usage." + return 1 + fi + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" + + while true; do + case "$1" in + -h|--help) + printf "pkgi: Install packages (distro-aware via get_pkgmgr).\n\n" + printf "Usage: pkgi [options] [package...]\n\n" + printf "Options:\n" + printf "\t-h, --help\tDisplay this help screen\n" + printf "\t-y, --yes\tDo not ask for confirmation (where the manager asks)\n" + printf "\t-n, --dry-run\tOnly display the command that would be run\n\n" + printf "Root privileges are obtained with sudo (or doas) when needed.\n" + return 0 + ;; + -y|--yes) + assume_yes=1 + shift + ;; + -n|--dry-run) + dry_run=1 + shift + ;; + --) + shift + break + ;; + *) + disp E "Invalid option: $1" + return 1 + ;; + esac + done + + if [[ $# -eq 0 ]]; then + disp E "Please specify at least one package name." + return 1 + fi + local pkg + for pkg in "$@"; do + # Not passed after "--": not every manager understands it + if [[ $pkg == -* ]]; then + disp E "Invalid package name: '$pkg'." + return 1 + fi + done + + local pkgmgr + pkgmgr=$(get_pkgmgr) || { + disp E "No usable package manager could be detected on this system." + return 2 + } + + local -a cmd=() + local needs_root=1 + case "$pkgmgr" in + apt) + cmd=(apt-get install) + (( assume_yes )) && cmd+=(-y) + ;; + dnf|yum) + cmd=("$pkgmgr" install) + (( assume_yes )) && cmd+=(-y) + ;; + zypper) + cmd=(zypper install) + (( assume_yes )) && cmd+=(-y) + ;; + pacman) + cmd=(pacman -S) + (( assume_yes )) && cmd+=(--noconfirm) + ;; + apk) + # apk add never asks for confirmation + cmd=(apk add) + ;; + portage) + cmd=(emerge) + (( assume_yes )) || cmd+=(--ask) + ;; + xbps) + cmd=(xbps-install -S) + (( assume_yes )) && cmd+=(-y) + ;; + nix) + # nix-env works on the user's profile: no root needed + cmd=(nix-env -i) + needs_root=0 + ;; + *) + disp E "Package manager '$pkgmgr' is not supported by pkgi." + return 2 + ;; + esac + + _pkg_run "$needs_root" "$dry_run" "${cmd[@]}" "$@" +} +export -f pkgi +# ------------------------------------------------------------------------------ + + +# ------------------------------------------------------------------------------ +# Uninstall packages with the package manager of the distribution. +# Usage: pkgr [options] [package...] +# -p, --purge also delete the configuration files (apt purge, pacman -n, +# apk --purge); managers without such a mode just remove +# -d, --deps also remove the dependencies no longer needed. Without it, +# the managers that do it by default (dnf, yum) are told not to +# (apk always cleans up: it has no way to prevent it) +pkgr() +{ + local purge=0 deps=0 assume_yes=0 dry_run=0 + + local PARSED + PARSED=$(getopt -o hpdyn --long help,purge,deps,yes,dry-run -n 'pkgr' -- "$@") + # shellcheck disable=SC2181 # getopt return code is checked immediately after + if [[ $? -ne 0 ]]; then + disp E "Invalid options, use \"pkgr --help\" to display usage." + return 1 + fi + # Restore the getopt-normalised (shell-quoted) argument list + eval set -- "$PARSED" + + while true; do + case "$1" in + -h|--help) + printf "pkgr: Uninstall packages (distro-aware via get_pkgmgr).\n\n" + printf "Usage: pkgr [options] [package...]\n\n" + printf "Options:\n" + printf "\t-h, --help\tDisplay this help screen\n" + printf "\t-p, --purge\tAlso delete configuration files (apt, pacman, apk)\n" + printf "\t-d, --deps\tAlso remove dependencies that are no longer needed\n" + printf "\t-y, --yes\tDo not ask for confirmation (where the manager asks)\n" + printf "\t-n, --dry-run\tOnly display the command that would be run\n\n" + printf "Root privileges are obtained with sudo (or doas) when needed.\n" + return 0 + ;; + -p|--purge) + purge=1 + shift + ;; + -d|--deps) + deps=1 + shift + ;; + -y|--yes) + assume_yes=1 + shift + ;; + -n|--dry-run) + dry_run=1 + shift + ;; + --) + shift + break + ;; + *) + disp E "Invalid option: $1" + return 1 + ;; + esac + done + + if [[ $# -eq 0 ]]; then + disp E "Please specify at least one package name." + return 1 + fi + local pkg + for pkg in "$@"; do + if [[ $pkg == -* ]]; then + disp E "Invalid package name: '$pkg'." + return 1 + fi + done + + local pkgmgr + pkgmgr=$(get_pkgmgr) || { + disp E "No usable package manager could be detected on this system." + return 2 + } + + local -a cmd=() post=() + local needs_root=1 purge_done=0 + case "$pkgmgr" in + apt) + # --auto-remove also drops the automatically installed packages + # nothing needs any more + if (( purge )); then cmd=(apt-get purge); purge_done=1; else cmd=(apt-get remove); fi + (( deps )) && cmd+=(--auto-remove) + (( assume_yes )) && cmd+=(-y) + ;; + dnf|yum) + cmd=("$pkgmgr" remove) + # These managers remove the unneeded dependencies by default + (( deps )) || cmd+=(--setopt=clean_requirements_on_remove=False) + (( assume_yes )) && cmd+=(-y) + ;; + zypper) + cmd=(zypper remove) + (( deps )) && cmd+=(--clean-deps) + (( assume_yes )) && cmd+=(-y) + ;; + pacman) + # -n: no .pacsave backups of the configuration files; + # -s: also remove dependencies nothing else needs + local flags="-R" + (( purge )) && { flags+="n"; purge_done=1; } + (( deps )) && flags+="s" + cmd=(pacman "$flags") + (( assume_yes )) && cmd+=(--noconfirm) + ;; + apk) + cmd=(apk del) + (( purge )) && { cmd+=(--purge); purge_done=1; } + ;; + portage) + cmd=(emerge --unmerge) + (( assume_yes )) || cmd+=(--ask) + # depclean removes everything no longer needed, as autoremove does + (( deps )) && post=(emerge --depclean) + ;; + xbps) + cmd=(xbps-remove) + (( deps )) && cmd+=(-R) + (( assume_yes )) && cmd+=(-y) + ;; + nix) + cmd=(nix-env -e) + needs_root=0 + # Dependencies live in the store: collect what is now unreachable + (( deps )) && post=(nix-collect-garbage) + ;; + *) + disp E "Package manager '$pkgmgr' is not supported by pkgr." + return 2 + ;; + esac + + if (( purge && ! purge_done )); then + disp W "$pkgmgr has no purge mode: the packages are removed normally." + fi + + _pkg_run "$needs_root" "$dry_run" "${cmd[@]}" "$@" || return $? + if (( ${#post[@]} )); then + # Only reached when the removal itself succeeded + _pkg_run "$needs_root" "$dry_run" "${post[@]}" + fi +} +export -f pkgr +# ------------------------------------------------------------------------------ + + # EOF