Skip to content

Instantly share code, notes, and snippets.

@konsolebox
Last active September 19, 2024 04:59
Show Gist options
  • Save konsolebox/ba31efab6609d200de3919197f1463b7 to your computer and use it in GitHub Desktop.
Save konsolebox/ba31efab6609d200de3919197f1463b7 to your computer and use it in GitHub Desktop.
#!/bin/bash -p
# ph (Portage Helper)
#
# An opinionated tool that provides useful commands for querying and
# modifying Portage-related stuff
#
# Usage: ph command [arguments]
#
# Copyright (c) 2024 konsolebox
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# “Software”), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
[ -n "${BASH_VERSION-}" ] && [[ BASH_VERSINFO -ge 5 ]] || {
echo "This scripts requires Bash version 5.0 or newer." >&2
exit 1
}
set -fu -o pipefail +o posix && shopt -s assoc_expand_once expand_aliases extglob nullglob || exit
_PH_WORLD_FILE=/var/lib/portage/world
function ph.common.err {
printf '%s\n' "$1" >&2
}
alias err=ph.common.err
function ph.common.die {
ph.common.err "$@"
exit 1
}
alias die=ph.common.die
function ph.common.print_mnemonic {
local msg=$1 fmsg u=$'\e[4m' r=$'\e[0m' c i j n t
for (( i = 0, j = ${#msg}; i < j; ++i )); do
c=${msg:i:1} n=${msg:i + 1:1}
if [[ $c == _ && $n ]]; then
if [[ $n == _ ]]; then
fmsg+=_
else
fmsg+=${u}${n}${r}
fi
(( ++i ))
else
fmsg+=$c
fi
done
printf '%s\n' "${fmsg}"
}
alias print_mnemonic=ph.common.print_mnemonic
function ph.common.get_opt_and_optarg {
local optional=false
if [[ $1 == @optional ]]; then
optional=true
shift
fi
OPT=$1 OPTARG= OPTSHIFT=0
if [[ $1 == -[!-]?* ]]; then
OPT=${1:0:2} OPTARG=${1:2}
elif [[ $1 == --*=* ]]; then
OPT=${1%%=*} OPTARG=${1#*=}
elif [[ ${2+.} && (${optional} == false || $2 != -?*) ]]; then
OPTARG=$2 OPTSHIFT=1
elif [[ ${optional} == true ]]; then
return 1
else
die "The '$1' option requires an argument." 2
fi
return 0
}
alias get_opt_and_optarg=ph.common.get_opt_and_optarg
function ph.common.check_external_command {
local __
for __; do
# The type command searches a hash table first so don't bother using a cache.
type -P "$__" >/dev/null || die "Required external command not found: $__"
done
}
function ph.common.check_package_name_internal {
local pkg=$1 parsed
ph.common.check_external_command qatom
[[ ${pkg} == +([!/])/+([!/]) ]] || return
parsed=$(qatom -F '%{pfx}%{C}/%{PF}:%{SLOT}/%{SUBSLOT}::%{REPO}' -- "${pkg}") || return
[[ ${parsed} ]] || return
parsed=${parsed%::<unset>} parsed=${parsed#<unset>}
[[ ${parsed%%:*} == +([!/])/+([!/]) ]] || return
[[ ${parsed} != *::* || ${parsed} == *::+([[:alnum:]_-]) ]] || return
parsed=${parsed//?<unset>}
[[ "${pkg}" == ${parsed} ]]
}
function ph.common.check_package_name {
ph.common.check_package_name_internal "$1" || die "Invalid or incomplete package name: $1" 2
}
function ph.common.get_filename_equivalent {
local __pkg=$1
local -n ___filename=$2
___filename=${pkg/\//.} ___filename=${___filename//\//,}
}
function ph.common.get_class_dir {
local __class=$1 __basename
local -n ___dir=$2
if [[ ${__class} == keywords ]]; then
if [[ -d /etc/portage/package.accept_keywords ]]; then
___dir=/etc/portage/package.accept_keywords
elif [[ -d /etc/portage/package.keywords ]]; then
___dir=/etc/portage/package.keywords
else
die "No package.accept_keywords or package.keywords directory was found in /etc/portage."
fi
else
if [[ ${__class} == restrict ]]; then
__basename=package.accept_restrict
else
__basename=package.${__class}
fi
if [[ -d /etc/portage/${__basename} ]]; then
___dir=/etc/portage/${__basename}
else
die "No ${__basename} directory found in /etc/portage."
fi
fi
}
function ph.common.get_filepath_equivalent {
local __class=$1 __pkg=$2 __dir __filename
local -n ___filepath=$3
ph.common.get_class_dir "${__class}" __dir
ph.common.get_filename_equivalent "${__pkg}" __filename
___filepath=${__dir}/${__filename}
}
function ph.common.detect_lone_help_option {
local i
for (( i = 1; i <= $#; ++i )); do
case ${!i} in
-h|--help)
return 0
;;
--)
breaks
;;
-[!-][!-]*)
set -- "${@:1:i - 1}" "${!i:0:2}" "-${!i:2}" "${@:i + 1}"
(( --i ))
;;
-?*)
die "Invalid option: $1" 2
;;
*)
;;
esac
done
return 1
}
function ph.common.get_arguments {
local -n __args=$1
local __
shift
__args=()
while __=${1-}; shift; do
if [[ $__ == -- ]]; then
__args+=("${@:2}")
break
elif [[ $__ != -?* ]]; then
__args+=("$__")
fi
done
[[ ${__args+.} ]]
}
function ph.common.has_arguments {
local void
ph.common.get_arguments void "$@"
}
function ph.common.disallow_options_blending {
[[ $# -lt 2 ]] || die "The '${opts}' option can't be specified along with '${opts[1]}'."
}
function ph.world.show_usage_and_exit {
print_mnemonic "Usage: ph _world [-h|--help] [subcommand]"
print_mnemonic "Subcommands: _add _find _list _remove _edit _print"
exit 2
}
function ph.world.add.show_usage_and_exit {
echo "Adds PKG to the world file"
print_mnemonic "Usage: ph _world _add <pkg> ..."
exit 2
}
function ph.world.add {
local pkg args entry_added=false
ph.common.check_external_command grep sed sort sponge
ph.common.detect_lone_help_option "$@" && ph.world.add.show_usage_and_exit
ph.common.get_arguments args "$@"
[[ ${args+.} ]] || die "No package names specified." 2
for pkg in "${args[@]}"; do
ph.common.check_package_name "${pkg}"
done
for pkg in "${args[@]}"; do
if grep -Fxe "${pkg}" "${_PH_WORLD_FILE}" >/dev/null; then
echo "Package name alredy added in world file: ${pkg}"
else
if [[ ${entry_added} == false ]]; then
echo "Creating a backup copy of the world file."
cp -a "${_PH_WORLD_FILE}" "${_PH_WORLD_FILE}.bak.new" || \
die "Failed to create a backup copy of the world file."
fi
echo "Adding '${pkg}' to world file."
printf '\n%s\n' "${pkg}" >> "${_PH_WORLD_FILE}" || {
err "Failed to add '${pkg}' to world file."
die "Consider comparing the world file with '${_PH_WORLD_FILE}.bak.new'."
}
entry_added=true
fi
done
if [[ ${entry_added} == true ]]; then
echo "Sorting world file entries."
sort -Vu "${_PH_WORLD_FILE}" | sed '/^[[:space:]]*$/d' | sponge "${_PH_WORLD_FILE}" || {
err "Failed to sort world file."
die "Please compare it with '${_PH_WORLD_FILE}.bak.new'."
}
echo "Saving the new backup file as '${_PH_WORLD_FILE}.bak'."
mv -- "${_PH_WORLD_FILE}.bak.new" "${_PH_WORLD_FILE}.bak" || \
die "Failed to save '${_PH_WORLD_FILE}.bak.new' as ${_PH_WORLD_FILE}.bak."
fi
}
function ph.world.find.show_usage_and_exit {
echo "Finds packags entrie containing the specified plain text expressions"
print_mnemonic "Usage: ph _world _find <expression> ..."
exit 2
}
function ph.world.find {
local expressions grep_cmd __
ph.common.check_external_command grep
ph.common.detect_lone_help_option "$@" && ph.world.find.show_usage_and_exit
ph.common.get_arguments expressions "$@"
[[ ${expressions+.} ]] || die "No expressions specified." 2
for __ in "${expressions[@]}"; do
[[ -z $__ ]] && die "Invalid empty expression: $__" 2
done
grep_cmd="grep -hFe ${expressions@Q} ${_PH_WORLD_FILE@Q}"
for __ in "${expressions[@]:2}"; do
grep_cmd+=" | grep -Fe ${__@Q}"
done
eval "${grep_cmd}"
}
function ph.world.list.show_usage_and_exit {
echo "Shows the world file's contents"
print_mnemonic "Usage: ph _world _list"
exit 2
}
function ph.world.list {
local args
ph.common.check_external_command cat
ph.common.detect_lone_help_option "$@" && ph.world.list.show_usage_and_exit
ph.common.get_arguments args "$@" && die "Arguments are unexpected: ${args[*]@Q}" 2
cat "${_PH_WORLD_FILE}"
}
function ph.world.remove.show_usage_and_exit {
echo "Removes PKG from the world file"
print_mnemonic "Usage: ph _world _remove <pkg> ..."
exit 2
}
function ph.world.remove {
local pkg args entry_removed=false
ph.common.check_external_command cp grep mv sponge
ph.common.detect_lone_help_option "$@" && ph.world.remove.show_usage_and_exit
ph.common.get_arguments args "$@"
[[ ${args+.} ]] || die "No package names specified." 2
for pkg in "${args[@]}"; do
ph.common.check_package_name "${pkg}"
done
for pkg in "${args[@]}"; do
if grep -Fxe "${pkg}" "${_PH_WORLD_FILE}" >/dev/null; then
if [[ ${entry_removed} == false ]]; then
echo "Creating a backup copy of the world file."
cp -a "${_PH_WORLD_FILE}" "${_PH_WORLD_FILE}.bak.new" || \
die "Failed to create a backup copy of the world file."
fi
echo "Removing '${pkg}' from the world file."
grep -vFxe "${pkg}" "${_PH_WORLD_FILE}" | sponge "${_PH_WORLD_FILE}" || {
err "Failed to remove '${pkg}' from the world file."
die "Consider comparing the world file with '${_PH_WORLD_FILE}.bak.new'."
}
entry_removed=true
else
echo "Package name not included in world file: ${pkg}"
fi
done
if [[ ${entry_removed} == true ]]; then
echo "Saving the new backup file as '${_PH_WORLD_FILE}.bak'."
mv -- "${_PH_WORLD_FILE}.bak.new" "${_PH_WORLD_FILE}.bak" || \
die "Failed to save '${_PH_WORLD_FILE}.bak.new' as ${_PH_WORLD_FILE}.bak."
fi
}
function ph.world.edit.show_usage_and_exit {
echo "Opens the world file with an editor"
print_mnemonic "Usage: ph _world _edit"
exit 2
}
function ph.world.edit {
ph.common.detect_lone_help_option "$@" && ph.world.edit.show_usage_and_exit
ph.common.has_arguments "$@" && die "The 'edit' command does not expect arguments."
${EDITOR:-vi} "${_PH_WORLD_FILE}"
}
function ph.world.print.show_usage_and_exit {
echo "Displays '${_PH_WORLD_FILE}'"
print_mnemonic "Usage: ph _world _print"
exit 2
}
function ph.world.print {
ph.common.detect_lone_help_option "$@" && ph.world.print.show_usage_and_exit
ph.common.has_arguments "$@" && die "The 'print' command does not expect arguments."
echo "${_PH_WORLD_FILE}"
}
function ph.world {
local command=()
local -A COMMAND_MAP=([a]=add [f]=find [l]=list [r]=remove [e]=edit [p]=print)
while [[ $# -gt 0 && -z ${command+.} ]]; do
case $1 in
add|find|list|remove|edit|print)
command=$1
;;
[aflrep])
command=${COMMAND_MAP[$1]}
;;
-h|--help)
ph.world.show_usage_and_exit
;;
--)
break
;;
-[!-][!-]*)
set -- "${1:0:2}" "-${1:2}" "${@:2}"
continue
;;
-*)
die "Invalid option: $1" 2
;;
*)
die "Invalid subcommand: $1" 2
;;
esac
shift
done
"ph.world.${command-list}" "$@"
}
function ph.main.get_ls_command {
local -n ___rvar=$1
if [[ -z ${_PH_LS_COMMAND+.} ]]; then
local columns=${COLUMNS:-$(type -P tput >/dev/null && tput cols)}
_PH_LS_COMMAND=(ls -C --color=auto --literal ${columns:+"--width=${columns}"})
fi
___rvar=("${_PH_LS_COMMAND[@]}")
}
function ph.main.show_usage_and_exit {
echo "Usage: ph class [-h|--help] [--] [arguments]"
print_mnemonic "Classes: _use _keywords _mask u_nmask _license _env _restrict _world"
exit 2
}
function ph.main.show_command_usage_and_exit {
local class=$1 argument_name class_dir ls_command usage=() usage_details=() __
local -A MNEMONIC_MAP=([use]=_use [keywords]=_keywords [mask]=_mask [unmask]=u_nmask
[license]=_license [env]=_env [restrict]=_restrict)
case ${class} in
mask|unmask)
usage=("<pkg>")
usage_details=("${class@u}s specified packages")
;;
keywords)
usage=("<pkg> [<options>] [--] [<keyword> ...]")
usage_details=("Keyword-unmasks specified package")
usage_details+=("Specific to keywords, an entry is still added even if no keyword was specified.")
ph.
argument_name="keyword"
;;
use)
usage=("<pkg> [<options>] [--] <use_flag> ...")
usage_details=("Sets USE flags on package")
argument_name="USE flag"
;;
license)
usage=("<pkg> [<options>] [--] <license> ...")
usage_details=("Sets licenses on package")
argument_name="license"
;;
env)
usage=("<pkg> [<options>] [--] <env_filename> ...")
usage_details=("Sets env entries on package")
argument_name="env filename"
;;
restrict)
usage=("<pkg> [<options>] [--] <restrict_token> ...")
usage_details=("Sets restrict tokens on package")
argument_name="restrict token"
;;
esac
printf '%s\n\n' "${usage_details}"
print_mnemonic "Usage: ph ${MNEMONIC_MAP[${class}]} ${usage//_/__}"
print_mnemonic " ph ${MNEMONIC_MAP[${class}]} <pkg> --s|--show"
for __ in "${usage[@]:1}"; do
print_mnemonic " pkg ${MNEMONIC_MAP[${class}]} ${__//_/__}"
done
print_mnemonic " ph ${MNEMONIC_MAP[${class}]} <options>"
print_mnemonic " ph ${MNEMONIC_MAP[${class}]}"
ph.common.get_class_dir "${class}" class_dir
ph.main.get_ls_command ls_command
echo "
Options:
-h, --help Show this help info
-s, --show Display all entries in the class directory, or just
the package's filename if a package is specified
Package-relevant options:
-e, --edit Open an editor to edit the package's existing
equivalent file
-f, --filename=FILENAME Use FILENAME as filename instead of the package
name's equivalent
Class-relevant options:
-b, --browse Open a filename manager using xdg-open
-l, --list Show lists of files in class directory
-L, --ls Show list of files in class directory using an ls command:
${ls_command[*]}
-p, --print-dir Display complete path name of the class directory
Class directory: ${class_dir}
"
[[ ${usage_details[1]+.} ]] && printf '%s\n' "${usage_details[@]:1}"
echo "Command defaults to '--show' behavior when no package or option is specified."
if [[ ${class} == @(keywords|use|license|restrict) ]]; then
echo "
Specifying '--' before a ${argument_name} is recommended to avoid negations being
interpreted as invalid options."
fi
exit 2
}
function ph.main.add_argument {
local class=$1 pkg=$2 argument=("${@:3:1}") dir entry_found=false filepath lines=()
ph.common.check_package_name "${pkg}"
if [[ ${3-} == --filename=* ]]; then
ph.common.get_class_dir "${class}" dir
filepath=${dir}/${3#--filename=}
else
ph.common.get_filepath_equivalent "${class}" "${pkg}" filepath
fi
if [[ -e ${filepath} ]]; then
readarray -t lines < "${filepath}"
local output=() IFS=$' \t\n'
for line in "${lines[@]}"; do
local words=(${line})
if [[ ${words} == "${pkg}" ]]; then
if [[ ${entry_found} == true ]]; then
die "Double entry of '${pkg}' found in '${filepath}'."
else
entry_found=true
fi
for word in "${words[@]:1}"; do
[[ ${word} == "${argument-}" ]] && return
done
[[ ${argument+.} ]] && words+=("${argument}")
line=${pkg}${words[1]+ ${words[*]:1}}
fi
output+=("${line}")
done
if [[ ${entry_found} == true ]]; then
printf '%s\n' "${output[@]}" > "${filepath}"
return
fi
fi
printf '%s\n' "${lines[@]}" "${pkg}${argument+ }${argument-}" > "${filepath}"
}
function ph.main.display_or_edit_package_file_contents {
local class=$1 pkg=$2 mode=$3 filename=(${4+"$4"}) dir filepath
ph.common.check_external_command cat
if [[ ${filename+.} ]]; then
ph.common.get_class_dir "${class}" dir
filepath=${dir}/${filename}
else
ph.common.check_package_name "${pkg}"
ph.common.get_filepath_equivalent "${class}" "${pkg}" filepath
fi
if [[ ${mode} == edit ]]; then
${EDITOR:-vi} "${filepath}"
elif [[ -e ${filepath} ]]; then
ph.common.check_external_command cat
cat "${filepath}"
else
die "Entry file doesn't exist: ${filepath}"
fi
}
function ph.main.display_package_file_contents {
ph.main.display_or_edit_package_file_contents "$1" "$2" display "${@:3}"
}
function ph.main.edit_package_file_contents {
ph.main.display_or_edit_package_file_contents "$1" "$2" edit "${@:3}"
}
function ph.main.run_command {
local class=$1 args=() browse_opt=() class_dir edit_opt=() filename=() filename_opt=() \
list_opt=() ls_opt=() opts pkg print_dir_opt=() show_opt=() __
shift
if [[ ${class} == world ]]; then
ph.world "$@"
return
fi
while [[ $# -gt 0 ]]; do
case $1 in
-b|--browse)
browse_opt=$1
;;
-e|--edit)
edit_opt=$1
;;
-f|--filename?(=*))
filename_opt=${1%%=*}
get_opt_and_optarg "${@:1:2}"
filename=${OPTARG}
;;
-h|--help)
ph.main.show_command_usage_and_exit "${class}"
;;
-l|--list)
list_opt=$1
;;
-L|--ls)
ls_opt=$1
;;
-p|--print-dir)
print_dir_opt=$1
;;
-s|--show)
show_opt=$1
;;
--)
args+=("${@:2}")
break
;;
-[!-][!-]*)
set -- "${1:0:2}" "-${1:2}" "${@:2}"
continue
;;
-?*)
die "Invalid option: $1" 2
;;
*)
args+=("$1")
;;
esac
shift
done
[[ ${filename+.} && ${filename} == @(|.|..|*/*) ]] && \
die "Invalid filename specified: ${filename}"
ph.common.disallow_options_blending ${edit_opt-} ${list_opt-} ${ls_opt-} ${print_dir_opt-} ${show_opt-}
ph.common.disallow_options_blending ${filename_opt-} ${list_opt-} ${ls_opt-} ${print_dir_opt-}
set -- "${args[@]}"
if [[ $# -gt 0 ]]; then
for __ in ${list_opt-} ${ls_opt-} ${print_dir_opt-}; do
die "The '$__' option can't be specified with a package." 2
done
pkg=$1
shift
if [[ $# -eq 0 ]]; then
if [[ ${edit_opt+.} ]]; then
ph.main.edit_package_file_contents "${class}" "${pkg}" "${filename[@]}"
elif [[ ${show_opt+.} ]]; then
ph.main.display_package_file_contents "${class}" "${pkg}" "${filename[@]}"
elif [[ ${class} == @(keywords|mask|unmask) ]]; then
ph.main.add_argument "${class}" "${pkg}" ${filename+--filename="${filename}"}
else
die "The '${class}' command requires an argument to the package."
fi
else
[[ ${class} == @(mask|unmask) ]] && \
die "The '${class}' command does not accept package arguments: $*"
[[ ${opts+.} ]] && die \
die "The '${opts}' option can't be specified along with package arguments: $*"
for __; do
ph.main.add_argument "${class}" "${pkg}" "${filename_opt[@]}" "$__"
done
fi
elif [[ ${filename+.} && (${edit_opt+.} || ${show_opt+.}) ]]; then
if [[ ${edit_opt+.} ]]; then
ph.main.edit_package_file_contents "${class}" - "${filename}"
elif [[ ${show_opt+.} ]]; then
ph.main.display_package_file_contents "${class}" - "${filename}"
fi
else
[[ ${edit_opt+.} ]] && \
die "The '${edit_opt}' option needs to be specified with a package or the '--filename' option."
[[ ${filename_opt+.} ]] && \
die "The '${filename_opt}' option needs to be specified with a package, the '--edit' option or the '--show' option."
ph.common.get_class_dir "${class}" class_dir
if [[ ${list_opt+.} ]]; then
(
cd "${class_dir}" || exit
set +f
files=() dirs=()
for f in *; do
if [[ -d $f ]]; then
dirs+=("$f")
else
files+=("$f")
fi
done
[[ ${dirs+.]} ]] && printf '%s/\n' "${dirs[@]}"
[[ ${files+.]} ]] && printf '%s\n' "${files[@]}"
)
elif [[ ${ls_opt+.} ]]; then
(
ph.main.get_ls_command ls_command
cd "${class_dir}" && exec "${ls_command[@]}"
)
elif [[ ${print_dir_opt+.} ]]; then
echo "${class_dir}"
else
ph.common.check_external_command grep sort
set +f
set -- "${class_dir}"/*
set -f
[[ $# -gt 0 ]] && grep -vhEe '^\s*(#|$)' "$@" | sort -V
fi
fi
}
function ph.main {
local class=() __
local -A CLASS_MAP=([u]=use [k]=keywords [m]=mask [n]=unmask [l]=license [e]=env [r]=restrict [w]=world)
while [[ $# -gt 0 && -z ${class+.} ]]; do
case $1 in
[ukmnlerw])
class=${CLASS_MAP[$1]}
;;
use|keywords|mask|unmask|license|env|restrict|world)
class=$1
;;
-h|--help)
ph.main.show_usage_and_exit
;;
--)
die "Unexpected separator: $1" 2
;;
-[!-][!-]*)
set -- "${1:0:2}" "-${1:2}" "${@:2}"
continue
;;
-?*)
die "Invalid option: $1" 2
;;
*)
die "Invalid class: $1" 2
;;
esac
shift
done
if [[ -z ${class+.} ]]; then
err "Class not specified."
ph.main.show_usage_and_exit >&2
fi
ph.main.run_command "${class}" "$@"
}
ph.main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment