├── PKGBUILD ├── UNLICENSE ├── README.md └── pellets /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Doug Patti 2 | 3 | pkgname=pellets 4 | pkgver=1.0.1 5 | pkgrel=1 6 | pkgdesc="manage installed Arch packages with a configuration file" 7 | arch=('any') 8 | url="https://github.com/dpatti/pellets" 9 | license=('Unlicense') 10 | depends=('bash') 11 | source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/$pkgver.tar.gz") 12 | md5sums=('268bdaaef8a1be7521ed5acd96025393') 13 | 14 | package() { 15 | install -Dm775 "$srcdir/$pkgname-$pkgver/pellets" "$pkgdir/usr/bin/pellets" 16 | } 17 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pellets 2 | 3 | ``` 4 | pellets [] [--aur-install ] 5 | ``` 6 | 7 | A declarative `pacman` wrapper for Arch Linux. The goal of pellets is to allow 8 | you to tersely specify what packages you expect to be installed and keep your 9 | system tidy by removing packages that aren't needed. It's also great for 10 | bootstrapping a new system. Pellets doesn't prevent you from installing new 11 | packages manually to try them out, but it will help you remember to keep a 12 | transcript of those you want to stay around. 13 | 14 | You start by specifying an annotated file of packages you want installed, like 15 | this: 16 | 17 | ``` 18 | # Apps 19 | google-chrome [aur] 20 | vlc 21 | 22 | # Tools 23 | maim # used to take screenshots 24 | 25 | # X11 26 | xorg [group] 27 | xmonad 28 | ``` 29 | 30 | Then, when you run `pellets`, it will look at the current explicitly-installed 31 | packages, compare them to the list, and install, remove, or modify to make your 32 | system match the config file. 33 | 34 | ``` 35 | $ pellets 36 | install maim 37 | remove scrot 38 | mark-explicit xorg-xrandr 39 | 40 | Proceed? [Y/n/dry]: 41 | ``` 42 | 43 | The specific actions it can take are: 44 | 45 | | in config | install reason | action | 46 | |-----------|----------------------|---------------------------| 47 | | yes | not installed | install | 48 | | yes | installed as dep | mark-explicit | 49 | | yes | explicitly installed | none | 50 | | no | not installed | none | 51 | | no | installed as dep | none | 52 | | no | explicitly installed | mark-dependency OR remove | 53 | 54 | Marking a package as an explicit installation or dependency is mostly for 55 | bookkeeping purposes. It uses this information to prompt to prune unused 56 | dependency packages after the previous step is complete. 57 | 58 | ## Config File 59 | 60 | You can specify a path to a config file on the command line. If none is 61 | specified, `pellets` will look in the default location using [XDG Base Directory 62 | specification][xdg] which is typically `~/.config/pellets/packages`. Note that 63 | even when run with `sudo`, `~` will resolve to the calling user's `$HOME` 64 | directory. If you are using `$XDG_CONFIG_HOME`, you must use `sudo -E` to 65 | preserve the environment. 66 | 67 | [xdg]: https://wiki.archlinux.org/title/XDG_Base_Directory 68 | 69 | A config file is simply a list of packages and comments. Comments begin with a 70 | `#` and can appear at any point in the line. Packages are specified one per 71 | line. There are two modifiers for packages: 72 | 73 | * `[aur]` indicates that this package is built via the Arch User Repository 74 | (see below) 75 | * `[group]` indicates that this is a package group 76 | 77 | You may also specify a provision instead of a package in the same way you could 78 | for pacman. This means that "python-neovim" and "python-pynvim" would both match 79 | against the `python-pynvim` package which *provides* "python-neovim". 80 | 81 | Provision and group resolution happens on each run. This means that if a group 82 | was installed and a package was later added to the group, that new package will 83 | be installed on the next run of pellets. This is *inconsistent* with how pacman 84 | works -- pacman only remembers the actual packages that were installed, not by 85 | what method they were installed. 86 | 87 | If a package is not explicitly listed and only *optionally* depended on by other 88 | packages, it will be removed. You should add it to your config if you wish to 89 | keep it. 90 | 91 | ### Starting out 92 | 93 | If you're starting from a blank slate, you can query what is currently installed 94 | on your system: 95 | 96 | ``` 97 | $ pacman -Q --quiet --explicit --native && pacman -Q --quiet --explicit --foreign | xargs printf "%s [aur]\n" 98 | ``` 99 | 100 | Many of these packages will come from groups. If you want to learn about what 101 | groups you might have installed, use `pacman -Q --explicit --groups` to get a 102 | hint. The most common groups are `base-devel` and `xorg`. 103 | 104 | ## AUR Packages 105 | 106 | By default, AUR packages are not installed since this isn't intended to be an 107 | entire toolchain for building AUR packages. Instead, you can optionally pass a 108 | command prefix with `--aur-install` that accepts packages on the command line. 109 | For example: 110 | 111 | ``` 112 | pellets --aur-install "aura -A" 113 | ``` 114 | 115 | Alternatively, you can place an executable file at the path 116 | `$XDG_CONFIG_HOME/pellets/aur-install` (where `$XDG_CONFIG_HOME` defaults to 117 | `~/.config`). If `--aur-install` is not specified and the file is present, it 118 | will be used. For example: 119 | 120 | ``` 121 | #!/usr/bin/env bash 122 | 123 | exec aura -A --unsuppress --noconfirm "$@" 124 | ``` 125 | 126 | ## Installation 127 | 128 | 129 | 130 | ## Publishing to AUR 131 | 132 | This section is mostly for me, for when I forget the steps next time. Push a new 133 | tag: 134 | 135 | ``` 136 | VERSION=1.0.0 137 | git tag $VERSION 138 | git push --tags 139 | ``` 140 | 141 | Check the digest: 142 | 143 | ``` 144 | curl -L https://github.com/dpatti/pellets/archive/refs/tags/$VERSION.tar.gz | md5sum 145 | ``` 146 | 147 | Edit `PKGBUILD`, updating `pkgver` and `md5sums`. Update AUR repo: 148 | 149 | ``` 150 | git clone aur@aur.archlinux.org/pellets.git aur 151 | cd aur 152 | ln ../PKGBUILD . 153 | makepkg 154 | makepkg --printsrcinfo | tee .SRCINFO 155 | git ci -am $VERSION 156 | ``` 157 | -------------------------------------------------------------------------------- /pellets: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2002 3 | # SC2002: Useless cat 4 | set -euo pipefail 5 | 6 | if [ -n "${XDG_CONFIG_HOME:-}" ]; then 7 | default_config_root=$XDG_CONFIG_HOME/pellets 8 | else 9 | user="${SUDO_USER:-$USER}" 10 | user_home=$(eval "echo ~$user") 11 | default_config_root=$user_home/.config/pellets 12 | fi 13 | 14 | default_config_file=$default_config_root/packages 15 | default_aur_install=$default_config_root/aur-install 16 | 17 | function usage { 18 | echo "usage: $0 [] [--aur-install ]" 19 | } 20 | 21 | function die { 22 | echo "$1" >&2 23 | usage >&2 24 | exit 1 25 | } 26 | 27 | config_file= 28 | 29 | while [ $# -gt 0 ]; do 30 | case $1 in 31 | -h|--help) 32 | usage 33 | echo 34 | echo " defaults to ~/.config/pellets/packages" 35 | echo " must accept multiple packages as arguments" 36 | echo " if --aur-install is not specified and ~/.config/pellets/aur-install exists" 37 | echo " and is executable, that will be used instead" 38 | exit 0 39 | ;; 40 | 41 | --aur-install) 42 | if [ -z "${2:-}" ]; then 43 | die "Missing AUR install command" 44 | fi 45 | aur_install_command=${2?} 46 | shift 2 47 | ;; 48 | 49 | *) 50 | if [ -n "$config_file" ]; then 51 | die "Unexpected: $1" 52 | fi 53 | config_file=$1 54 | shift 55 | ;; 56 | esac 57 | done 58 | 59 | if [ -z "$config_file" ]; then 60 | config_file=$default_config_file 61 | fi 62 | 63 | if [ ! -r "$config_file" ]; then 64 | die "Cannot read config file: $config_file" 65 | fi 66 | 67 | config=$(cat "$config_file") 68 | 69 | if [ -z "$config" ]; then 70 | die "Empty config file; aborting" 71 | fi 72 | 73 | if [ -z "${aur_install_command:-}" ] && [ -x "$default_aur_install" ]; then 74 | aur_install_command=$default_aur_install 75 | fi 76 | 77 | function strip-comments { sed 's/#.*$//'; } 78 | function strip-whitespace { sed 's/ *//g; /./!d'; } 79 | 80 | function assumed { 81 | echo ' 82 | base 83 | linux 84 | linux-firmware 85 | ' 86 | } 87 | 88 | # Parse config 89 | packages=$({ echo "$config"; assumed; } | strip-comments | strip-whitespace) 90 | function packages { echo "$packages"; } 91 | 92 | # We sort here because we're stripping extra text off 93 | function filter { grep "$@" || : no matches; } 94 | function without-modifier { filter --invert-match '\[.*\]' | sort -u; } 95 | function with-modifier { 96 | local pattern=" *\[$1\]$" 97 | filter --ignore-case "$pattern" | sed "s/$pattern//i" | sort -u 98 | } 99 | 100 | # Split config into three kinds 101 | official=$(packages | without-modifier) 102 | aur=$(packages | with-modifier 'aur') 103 | groups=$(packages | with-modifier 'group') 104 | 105 | # We sort here because pacman returns in sorted order that isn't necessarily 106 | # aligned with our LC_COLLATE, causing `comm` to misbehave. 107 | function query { pacman -Q --quiet "$@" | sort -u || : no results; } 108 | 109 | # `unroll str cmd ...` runs `cmd ...` passing individual lines of string as 110 | # separate quoted arguments. 111 | function unroll { 112 | if [ -n "$1" ]; then 113 | readarray -t args <<< "$1" 114 | shift 115 | "$@" "${args[@]}" 116 | fi 117 | } 118 | 119 | # resolve-packages allows us to specify the provision in our config, e.g., 120 | # "python-neovim" which you can pass to pacman to install the package 121 | # "python-pynvim". We resolve via -S (remotely) because if we have any new 122 | # packages, we won't be able to find them with -Q. 123 | function resolve-packages { 124 | # --nodeps twice will make sure we only print the single package 125 | if ! pacman -S --nodeps --nodeps --print-format "%n" "$@" | sort -u; then 126 | echo "There were some errors resolving packages -- did you forget to mark [group] or [aur]?" >&2 127 | return 1 128 | fi 129 | } 130 | 131 | # Resolve and combine config 132 | ungrouped=$(unroll "$groups" query --groups) 133 | resolved=$(unroll "$official" resolve-packages) 134 | combined=$(sort -u --merge <(echo -n "$resolved") <(echo -n "$aur") <(echo -n "$ungrouped")) 135 | 136 | # Query the current state of pacman's db 137 | all=$(query) 138 | explicit=$(query --explicit) 139 | unrequired=$(query --explicit --unrequired --unrequired) 140 | 141 | # `set-diff str1 str2` returns lines in str1 that are not in str2. Both strings 142 | # must be sorted. 143 | function set-diff { comm -23 <(echo -n "$1") <(echo -n "$2"); } 144 | 145 | # Use set differences 146 | extra_explicit=$(set-diff "$explicit" "$combined") 147 | need_asdeps=$(set-diff "$extra_explicit" "$unrequired") 148 | need_remove=$(set-diff "$extra_explicit" "$need_asdeps") 149 | 150 | missing_explicit=$(set-diff "$combined" "$explicit") 151 | need_install=$(set-diff "$missing_explicit" "$all") 152 | need_asexplicit=$(set-diff "$missing_explicit" "$need_install") 153 | need_sync=$(set-diff "$need_install" "$aur") 154 | need_aur=$(set-diff "$need_install" "$need_sync") 155 | 156 | function can-aur-install { [ "${aur_install_command:-}" != "" ]; } 157 | 158 | function color { printf "\e[1;%dm%s\e[0m" "$1" "$2"; } 159 | function green { color 32 "$1"; } 160 | function red { color 31 "$1"; } 161 | function black { color 30 "$1"; } 162 | function yellow { color 33 "$1"; } 163 | 164 | function aur-color { 165 | if can-aur-install; then 166 | green "$@" 167 | else 168 | black "$@" 169 | fi 170 | } 171 | 172 | needs="" 173 | 174 | function print-all { 175 | prefix=$1 176 | lines=$2 177 | 178 | if [ -n "$lines" ]; then 179 | needs="$needs$lines" 180 | echo "$lines" | while read -r line; do 181 | echo "$prefix $line" 182 | done 183 | fi 184 | } 185 | 186 | print-all "$(red "remove")" "$need_remove" 187 | print-all "$(green "install")" "$need_sync" 188 | print-all "$(yellow "mark-dependency")" "$need_asdeps" 189 | print-all "$(yellow "mark-explicit")" "$need_asexplicit" 190 | print-all "$(aur-color "aur-install")" "$need_aur" 191 | 192 | function prompt { 193 | read -r -p "$1" 194 | 195 | case "$REPLY" in 196 | N|n) exit 1 ;; 197 | d|dry) dry=1 ;; 198 | Y|y|"") dry= ;; 199 | *) echo "I didn't understand '$REPLY'" >&2; exit 1 ;; 200 | esac 201 | } 202 | 203 | function pacman! { 204 | if [ "$dry" == 1 ]; then 205 | echo pacman "$@" 206 | else 207 | command pacman "$@" 208 | fi 209 | } 210 | 211 | if [ -z "$needs" ]; then 212 | echo "Up to date" 213 | else 214 | function aur-install { 215 | if ! can-aur-install; then 216 | echo "You must manually install AUR packages:" "$@" 217 | elif [ "$dry" == 1 ]; then 218 | echo "$aur_install_command" "$@" 219 | else 220 | command $aur_install_command "$@" 221 | fi 222 | } 223 | 224 | echo 225 | prompt "Proceed? [Y/n/dry]: " 226 | 227 | unroll "$need_remove" pacman! -R --unneeded 228 | unroll "$need_sync" pacman! -S 229 | unroll "$need_asdeps" pacman! -D --asdeps 230 | unroll "$need_asexplicit" pacman! -D --asexplicit 231 | 232 | echo 233 | unroll "$need_aur" aur-install 234 | fi 235 | 236 | # Query for unused dependencies now that the db has potentially changed 237 | unused=$(query --deps --unrequired --unrequired) 238 | 239 | if [ -n "$unused" ]; then 240 | echo 241 | print-all "$(red "prune")" "$unused" 242 | 243 | echo 244 | prompt "Remove unused packages and their unused dependencies? [Y/n/dry]: " 245 | 246 | unroll "$unused" pacman! -R --recursive 247 | fi 248 | --------------------------------------------------------------------------------