#!/bin/bash shellfu import exit shellfu import inigrep shellfu import pretty # # Saturnin - __MKIT_PROJ_TAGLINE__ # # Saturnin is a Shellfu module that can help you build consistent and easy # to use command line-based toolkit. The idea is that you have a set of # scripts that together form a particular tool, which you then integrate # under a single meta-command (similar to git, apt, systemctl and many # others...). # # Saturnin's main goal is to help you with the integration so that you can # fully concentrate on development of the scripts, while Saturnin will # provide the common part of command-line interface. Adding to that, # Saturnin also provides several extra features as config management, # hooks or support for command line completion. # # It's possible to integrate any kind of programs within Saturnin, although # extra features are currently only available for Shellfu/Bash scripts. # # Starting your toolkit # ===================== # # The starting point of a Saturnin-powered toolkit is the main meta-command, # which sets all mandatory variables and calls saturnin__main(). You could # implement that one yourself, but it's **strongly recommended** to start # off by creating a copy of 'app.skel' from 'saturnin-demo': # # https://github.com/AloisMahdal/saturnin-demo/ # # In fact, it's best to actually copy the *whole* project, which serves # as skeleton of not just the meta-command (by Saturnin) but also MKit # install/build system, which provides features like installation # scripts, SemVer-compatible versioning and packaging templates for both # RPM and DEB: # # git clone https://github.com/AloisMahdal/saturnin-demo # mv saturnin-demo myproject # cd myproject # rm -rf .git # git init # # You will need to edit few files, for starters: # # edit mkit.ini # your main project info # edit packaging/template.spec # file lists, Requires and description # edit packaging/debian/control # ..also for Debian # git add . # git commit -m "Initial commit" # # ...but the reward is sweet: # # make install # to install to current system # # make rpmstuff # to get .spec file and source tarball # make debstuff # to get debian directory # make vbump # to seal off release # make release # to seal off release # # # Git commit hash of application source tree # # This is supposed to be set by your build scripts when building your # application. The string is returned by calling your meta-command with # option --saturnin-app-git-hash. # # Look for 'satcmd' template for a working example. # SATURNIN_APP_GIT_HASH=${SATURNIN_APP_GIT_HASH:-} # # Your application version # # This is supposed to be set by your build scripts when building your # application. The string is returned by calling your meta-command with # options --version, --version-semver or --saturnin-app-version. # # Look for 'satcmd' template for a working example. # SATURNIN_APP_VERSION=${SATURNIN_APP_VERSION:-} # # Path to user cache # SATURNIN_CACHE_HOME=${SATURNIN_CACHE_HOME:-} # # Path where saturnin__conf should look for files # # If filename does not contain slash, it is looked up in each (or all, # based on strategy--see saturnin__conf() doc) path in this list. The # list is colon-separated and non-dirs as well as empty strings are # silently ignored. # SATURNIN_CONF_PATH="${SATURNIN_CONF_PATH:-}" # # Expected config filename extension (for guessing from path head) # # If no filename to read is given, saturnin__conf() will guess filename # as the path head plus this suffix (e.g. `foo.ini` for `saturnin__conf # foo.bar.baz`) # SATURNIN_CONF_SUFFIX="${SATURNIN_CONF_SUFFIX:-.ini}" # # Directory where to look for subcommands # # Files here starting with $SATURNIN_LIBEXEC_PREFIX are considered # subcommands # SATURNIN_LIBEXEC="${SATURNIN_LIBEXEC:-}" # # Subcommand file prefix # # This is recommended to be set to meta-command name plus dash. For # example, if your meta-command is `mykit`, this should be set to # `mykit-`. # SATURNIN_LIBEXEC_PREFIX="${SATURNIN_LIBEXEC_PREFIX:-}" # # Meta-command help mode # # This controls what is displayed when user calls meta-command with --help # argument. Following formats are supported: # # +HELPFILE # =HELPFILE # # In both cases, HELPFILE must be absolute path to a file containing # human-readable description of the meta-command, which will be directly # presented to user. # # If the leading character is '=' (equal sign), the help text consists of, # and only of the HELPFILE. If the character is '+' (plus sign), the help # text is pre-pended with auto-generated usage message. # # If the value is empty, only the auto-generated usage message is printed. # SATURNIN_META_HELP="${SATURNIN_META_HELP:-}" # # Current running subcommand # # The value is set and exported into sub-command binary at moment of # saturnin__main() call. # SATURNIN_SUBCOMMAND="${SATURNIN_SUBCOMMAND:-}" saturnin__bug() { # # Warn about bug in your software # # Issue warning using warn() from pretty but also add application # version. This is useful when an assertion in your application fails # such that it is certain that there is a bug inside it. # # In such cases, it makes sense to print also version information to # help users with reporting. # local msg # message core for msg in "$@"; do warn "bug: $msg" done warn "bug in $(basename "$0") version: $SATURNIN_APP_VERSION" } saturnin__conf() { # # inigrep smart loader # # Usage: # saturnin__conf [-j] [inigrep-query] [-- [file]..] # # # File arguments # ============== # # If omitted, *file* argument is inferred by taking part of kpath name # before first dot and appending value of `$SATURNIN_CONF_SUFFIX`, # (".ini" by default). # # Each *file* argument is then processed as follows: # # * `-` (single dash) is interpreted as reading from standard input. # # * If argument contains slash, it is expanded as a regular path # (relative or absolute). # # * Otherwise, it is taken as filename and searched for in directories # given in `$SATURNIN_CONF_PATH`. (This can yield more than one # path, which is equivalent as if all paths were provided.) # # Not all files expanded based on `$SATURNIN_CONF_PATH` are read by # default; reading is governed by "merge strategy": the default # strategy "first" reads only the first existing file. # # "join" strategy on the other hand, means that any files are simply # concatenated and prefixed with comment (visible only in raw mode) # containing path to the file. # # This means that if a section is queried that is present in both # files, it is effectively concatenated as well. # # Following calls are equivalent # # saturnin__conf foo.bar.baz # saturnin__conf foo.bar.baz foo.ini # # and both result in reading of key *baz* from section *foo.bar* in file # *foo.ini*, which is selected from *SATURNIN_CONF_PATH*. Should there # be more foo.ini's, the first is selected. Using `-j` switch # # saturnin__conf -j foo.bar.baz # # would cause all foo.ini's on *SATURNIN_CONF_PATH* be concatenated # instead. # local ig_mode # retrieval mode local ig_query # keypath or section name (when listing keys) local ig_limit # line limit local files=() # file specification local Strategy=first # merge strategy while true; do case $1:$2 in "":*) break ;; -j:*) Strategy=join; shift 1 ;; -1:*) ig_limit=$1; shift 1 ;; -e:*.*) ig_mode=$1; ig_query=$2; shift 2; break ;; -r:*.*) ig_mode=$1; ig_query=$2; shift 2; break ;; -K:*) ig_mode=$1; ig_query=$2; shift 2; break ;; -S:*) ig_mode=$1; ig_query=""; shift 1; break ;; -P:*) ig_mode=$1; ig_query=""; shift 1; break ;; .*:*) _saturnin__conf_usage -w "bad syntax: $*" ;; *.*:*) ig_mode=-e; ig_query=$1; shift 1; break ;; --help:*) _saturnin__conf_usage -e 0 ;; *) _saturnin__conf_usage -w "bad syntax: $*" ;; esac done test -n "$ig_mode" || _saturnin__conf_usage -w "could not determine inigrep mode" debug -v ig_limit ig_query ig_mode Strategy if test -n "$*"; then files=("$@") elif test -n "$ig_query"; then files=("${ig_query%%.*}$SATURNIN_CONF_SUFFIX") else _saturnin__conf_usage -w "dunno what to load" fi debug -v files #shellcheck disable=SC2086 _saturnin__conf__load "${files[@]}" | inigrep $ig_limit $ig_mode "$ig_query" return "${PIPESTATUS[0]}" } saturnin__conf_find() { # # Find all existing instances of sub-path $1 on $SATURNIN_CONF_PATH # # Usage: # # saturnin__conf_find SUBPATH # # Go through all elements of $SATURNIN_CONF_PATH, looking for file or # directory, whose path is formed by joining SUBPATH to element of # $SATURNIN_CONF_PATH. Print each existing path, ignore rest. # # For example, with following setup: # # SATURNIN_CONF_PATH=foo:bar:baz # mkdir -p foo/one bar/one # mkdir -p bar/two/slashes # # call # # saturnin__conf_find one # # would print `foo/one` and `bar/one`, while # # saturnin__conf_find two/slashes # # would print `bar/two/slashes`. # # If at least one path was found, return zero. Otherwise, return one, # or more in case of error. # local file=$1 # sub-path to find local trydir # each item of $SATURNIN_CONF_PATH local trypath # each combined path debug -v SATURNIN_CONF_PATH echos "$SATURNIN_CONF_PATH" \ | tr ':' '\n' \ | while read -r trydir; do test -n "$trydir" || continue trypath="$trydir/$file" test -e "$trypath" || continue echos "$trypath" done \ | grep . } saturnin__get() { # # Show Saturnin internal info by key $1 and exit # # Key $1 can be whole `--saturnin-get-stuff` argument or just the part # after `--saturnin-get-`. # # This is aimed to help debugging and testing the app (or Saturnin # itself) by showing packaging and deployment related info. # local key=${1#--saturnin-get-} # internal info key case "$key" in saturnin-conf-path) echo "$SATURNIN_CONF_PATH" ;; saturnin-version) echo "__MKIT_PROJ_VERSION__" ;; app-git-hash) echo "$SATURNIN_APP_GIT_HASH" ;; app-version) echo "$SATURNIN_APP_VERSION" ;; cache-home) echo "$SATURNIN_CACHE_HOME" ;; libexec) echo "$SATURNIN_LIBEXEC" ;; libexec-prefix) echo "$SATURNIN_LIBEXEC_PREFIX" ;; subcommands) saturnin__lssc ;; *) warn "unknown devel key: $key" exit "$EXIT_USAGE" ;; esac exit "$EXIT_OK" } saturnin__lssc() { # # List subcommands # find "$SATURNIN_LIBEXEC" \ -mindepth 1 \ -maxdepth 1 \ -executable \ -name "$SATURNIN_LIBEXEC_PREFIX*" \ | sed -e "s|^.*/||; s|^$SATURNIN_LIBEXEC_PREFIX||" \ | sort } saturnin__main() { # # Main meta-command entry function # # After setting all mandatory environment variables, call this from your # main meta-command script. # local subcommand # subcommand to execute (first non-option) test -n "$SATURNIN_CACHE_HOME" || die "SATURNIN_CACHE_HOME is not set" test -n "$SATURNIN_LIBEXEC" || die "SATURNIN_LIBEXEC is not set" test -n "$SATURNIN_LIBEXEC_PREFIX" || die "SATURNIN_LIBEXEC_PREFIX is not set" while true; do case $1 in -D|--full-debug) export PRETTY_DEBUG=true export PRETTY_DEBUG_EXCLUDE="" shift ;; -d|--debug) export PRETTY_DEBUG=true; shift ;; -v|--verbose) export PRETTY_VERBOSE=true; shift ;; -h|--help) saturnin__help; exit ;; --version) saturnin__version; exit ;; -V|--version-semver) saturnin__get app-version ;; --saturnin-get-*) saturnin__get "$1" ;; -*) saturnin__usage -w "unknown argument: $1" ;; --*) saturnin__usage -w "unknown argument: $1" ;; --) shift; break ;; "") saturnin__usage -w "too few arguments" ;; *) break; ;; esac done subcommand="$1"; shift PRETTY_DEBUG_EXCLUDE="" debug -v SATURNIN_APP_VERSION BASH_VERSION debug -v SATURNIN_CONF_PATH case "$subcommand" in conf) saturnin__conf "$@" ;; *) saturnin__runsc "$subcommand" "$@" ;; esac } saturnin__help() { # # Print meta-command help text # # See $SATURNIN_META_HELP for details. # local introline # introduction line introline=$(basename "$0") test -n "$SATURNIN_APP_TAGLINE" \ && introline+=" - $SATURNIN_APP_TAGLINE" case "$SATURNIN_META_HELP" in "") echo "$introline"$'\n' >&2 saturnin__usage -E -e 0 ;; +/*) echo "$introline"$'\n' >&2 saturnin__usage -E echo >&2 _saturnin__cat_helpfile "${SATURNIN_META_HELP:1}" ;; =/*) _saturnin__cat_helpfile "${SATURNIN_META_HELP:1}" ;; *) echo "$introline"$'\n' >&2 saturnin__usage -E saturnin__bug "malformed SATURNIN_META_HELP: $SATURNIN_META_HELP" return 3 ;; esac } saturnin__conf_mkpath() { # # Compose new value for $SATURNIN_CONF_PATH from locations $@ # # Usage: # # saturnin__conf_mkpath DIR [DIR..] # # Go through each DIR and print it, unless it ends with "/ini.d", # in which case list its subdirectories, sorted by C locale (this allows # for modular configuration). # # Non-existent or non-directory items are silently ignored. # local location # one location argument local path # one path listed for location in "$@"; do test -d "$location" || continue case "$location" in */ini.d) # modular location--sort subfolders find -L "$location" -mindepth 1 -maxdepth 1 -type d \ | LC_ALL=C sort ;; *) echo "$location" ;; esac done \ | _saturnin__nl2colon } saturnin__runhook() { # # Run custom hook named $1 from respective configuration section # # Will load joined multi-line key "hook.$SATURNIN_SUBCOMMAND.$1" and # unless syntax check fails, execute it as Bash code (in separate # process). # local name="$1" # hook name local code # ... code test -n "$SATURNIN_SUBCOMMAND" || { warn "unknown subcommand, ignoring hook: $name" return 0 } code="$(saturnin__conf -j "hook.$SATURNIN_SUBCOMMAND.$name")" debug -v SATURNIN_SUBCOMMAND code name bash -n <<<"$code" || { warn "syntax errors, ignoring hook: $name" return 0 } bash <<<"$code" } saturnin__runsc() { # # Run subcommand $1 with arguments $2.. # local subcommand="$1"; shift # subcommand to run local binpath # path to subcommand's binary binpath+="$SATURNIN_LIBEXEC/" binpath+="$SATURNIN_LIBEXEC_PREFIX$subcommand" debug -v binpath debug "\$*='$*'" test -x "$binpath" \ || saturnin__usage -w "invalid sub-command: $subcommand" SATURNIN_SUBCOMMAND="$subcommand" "$binpath" "$@" } saturnin__usage() { # # Show usage message and exit # #shellcheck disable=SC2046 mkusage "$@" \ "[options] COMMAND [ARG...]" \ -o \ "-D, --full-debug turn on gory debugging" \ "-V, --version show version and exit" \ "-d, --debug turn on debugging" \ "-h, --help show this help message and exit"\ "-v, --verbose turn on verbosity" \ -c \ $(saturnin__lssc) } saturnin__version() { # # Print human-readable version info # # Basic version info is already stored in $SATURNIN_APP_VERSION, # this function prints more descriptive paragraph including Saturnin's # own version. # echo -n "$(basename "$0")" test -n "$SATURNIN_APP_TAGLINE" \ && echo -n " ($SATURNIN_APP_TAGLINE)" echo -n " $SATURNIN_APP_VERSION" test -n "$SATURNIN_APP_CODENAME" \ && echo -n " - $SATURNIN_APP_CODENAME" echo echo -n "Powered by Saturnin (__MKIT_PROJ_TAGLINE__)" echo -n " __MKIT_PROJ_VERSION__" echo -n " - __MKIT_PROJ_CODENAME__" echo return "$EXIT_OK" } saturnin__wraphook() { # # Wrap command $@ in hooks 'pre' and 'post' # # Run pre hook, then command $@, then post hook. Always exit with # status of the payload command, even if hooks fail. Ignore post-hook # if payload command failed. # local es=0 # exit status of this function saturnin__runhook pre "$@" || return $? es=$? saturnin__runhook post return $es } # # that what you see below this line # # INTERNAL # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # # use in your code to anger the divine # _saturnin__cat_helpfile() { # # Print helpfile $1 # local helpfile=$1 # path to help file cat "$helpfile" >&2 && return 0 saturnin__bug "cannot print help file: $helpfile" return 3 } _saturnin__conf__merge() { # # Take paths and applying merge strategy, load file(s) # local path # every path local found=false # 'true' if we got any path while read -r path; do found=true case $Strategy in first) debug "winner: $path" cat "$path" cat >/dev/null # throw away rest of paths ;; join) echo "# file: ${path/$HOME/~}" cat "$path" 2>/dev/null ;; esac done $found } _saturnin__conf__load() { # # Print contents of files specified in $@ # # Each argument means possible file candidate. If candidate contains # slash, it's treated as file path and is printed directly. If it's # single dash, standard input is copied. # # In all other cases, filename is searched in all elements of variable # SATURNIN_CONF_PATH; output then depends on chosen $Strategy: with # 'first' strategy, first existing file is printed, with 'join' # strategy, all existing files are printed. # local arg # each passed argument local es=0 # exit status of this function for arg in "$@"; do case $arg in -|*/*) # stdin, or path (with slash) cat "$arg" || es=3 ;; *) # name given, find all its incarnations saturnin__conf_find "$arg" \ | _saturnin__conf__merge; es=$? ;; esac done return $es } _saturnin__conf_usage() { # # Show usage message, passing $@ to mkusage() and exit # PRETTY_USAGE="self=${0##*/} conf" \ mkusage "$@" \ "[options] [-e] SECTION.KEY [FNAME]" \ "[options] -r SECTION.KEY [FNAME]" \ "[options] -K SECTION [FNAME]" \ "[options] -P FNAME" \ "[options] -S FNAME" \ -- \ "Use inigrep to query config files." \ -o \ "-j join all files before applying query" \ "-1 ensure single line is returned" \ -c \ "-e use normal mode (default)" \ "-r use raw mode (preserves RHS whitespace and some comments)"\ "-K list available keys in SECTION" \ "-S list available sections in FNAME" \ "-P list available keypaths (SECTION.KEY) in FNAME" \ -- \ "FNAME is filename, which is then searched on all paths specified"\ "in SATURNIN_CONF_PATH and depending on -j parameter, first one" \ "wins or all are joined. If FNAME contains slash, this search is"\ "not done and FNAME is taken as path to file that is then" \ "queried." \ "" \ "If FNAME is omitted, it is inferred from SECTION (e.g. 'foo.ini'"\ "if 'foo.bar' was section name; note that section name itself may"\ "contain dot)." } _saturnin__nl2colon() { # # Convert newline-based list of paths to colon:based:list # # Empty paths must not be included in the resulting list, so we need to # drop them and also get the colons right. # local idx=0 # current item index (zero-based) local path # each path on stdin while read -r path; do test -z "$path" && continue test $idx -gt 0 && echo -n ':' echo -n "$path" ((idx++)) done } #shellfu module-version=__MKIT_PROJ_VERSION__