It was `panama app claude-desktop`: a source build, because nothing packaged it. Upstream publishes an RPM repository now, so the exception shrinks to adding that repository -- and the result upgrades with `dnf upgrade` along with everything else instead of needing a slow rebuild every time a version ships. That moves it out of setup/apps/ and into the third-party section of install-packages, beside Bun, Claude Code and RustDesk. It also means it is part of ./install for the first time. The reason it was kept out was the build: slow, wants the network throughout, and fails on an upstream that moved, which is the failure the interview exists to prevent. A dnf install is none of those, and the default dock pins com.anthropic.Claude, so a fresh machine was shipping a pin for something the install had never put there. The repository is added with upstream's own setup script rather than a .repo file written out here. A baseurl copied into this repository is a pin by another name; the script is the part upstream keeps correct. Both halves are skipped when already present, so a re-run costs nothing. The desktop entry id does not change -- it is still com.anthropic.Claude -- so the dock pin and the notification rules carry over untouched. The new package drops the nodejs dependency, which is what used to hide the missing-npm bug the launcher search contract guards; its comment said "depends" in the present tense and now says what actually happened.
539 lines
19 KiB
Bash
Executable File
539 lines
19 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# panama – Helper for managing the Panama dotfiles/repo
|
||
# Version 1.0
|
||
# Author: Gabriel Brown
|
||
#
|
||
# Commands:
|
||
# update Commit & sync local changes (or just pull if clean)
|
||
# edit Open the Panama repo in Neovim
|
||
# doctor Report what is actually running on this machine
|
||
# test Run every contract under tests/
|
||
# upgrade Re-run the installer from anywhere
|
||
# apps Choose applications to install, by category
|
||
# app Build and install an application that no repository packages
|
||
# help Show this help
|
||
#
|
||
# Designed to grow: add new subcommands as cmd_<name> functions and
|
||
# register them in the dispatcher / usage block below.
|
||
|
||
set -euo pipefail
|
||
|
||
PROGRAM=$(basename "$0")
|
||
VERSION="1.0"
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Locate the Panama repo (this script lives in <repo>/bin/panama)
|
||
# ----------------------------------------------------------------------------
|
||
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
|
||
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Pretty output
|
||
# ----------------------------------------------------------------------------
|
||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
|
||
BOLD=$(tput bold); RESET=$(tput sgr0)
|
||
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3)
|
||
BLUE=$(tput setaf 4); MAGENTA=$(tput setaf 5); CYAN=$(tput setaf 6)
|
||
else
|
||
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; MAGENTA=""; CYAN=""
|
||
fi
|
||
|
||
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
|
||
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
|
||
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
|
||
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
|
||
header(){ printf '\n%s%s%s\n' "${MAGENTA}${BOLD}" "$*" "$RESET"; }
|
||
|
||
# Ask a yes/no question. Returns 0 for yes, 1 for no. Default = no.
|
||
confirm() {
|
||
local prompt="$1" reply
|
||
printf '%s?%s %s %s[y/N]%s ' "${CYAN}${BOLD}" "$RESET" "$prompt" "$BOLD" "$RESET"
|
||
read -r reply || true
|
||
[[ "$reply" =~ ^[Yy]([Ee][Ss])?$ ]]
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Usage
|
||
# ----------------------------------------------------------------------------
|
||
usage() {
|
||
cat <<EOF
|
||
${BOLD}$PROGRAM${RESET} – manage the Panama repo (${PANAMA_DIR})
|
||
|
||
${BOLD}Usage:${RESET}
|
||
$PROGRAM <command> [options]
|
||
|
||
${BOLD}Commands:${RESET}
|
||
${GREEN}update${RESET} Review, commit & sync local changes. If the working tree is
|
||
clean it simply runs 'git pull'.
|
||
${GREEN}edit${RESET} Open the Panama repo in Neovim.
|
||
${GREEN}doctor${RESET} Report what is actually running on this machine, rather
|
||
than what was installed. Takes --summary for one line per check.
|
||
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||
${GREEN}upgrade${RESET} Re-run ./install from anywhere. Safe: every stage is
|
||
idempotent and this is the documented upgrade path.
|
||
${GREEN}apps${RESET} Choose applications to install: pick a category, then tick
|
||
what you want. The same catalog ./install offers, minus the
|
||
install.
|
||
${GREEN}app${RESET} Build and install an application that neither dnf nor
|
||
Flathub carries. With no name, lists what is available.
|
||
${GREEN}help${RESET} Show this help (also -h, --help).
|
||
|
||
${BOLD}Options:${RESET}
|
||
-h, --help Show this help and exit
|
||
--version Show version and exit
|
||
|
||
${BOLD}Examples:${RESET}
|
||
$PROGRAM update
|
||
$PROGRAM edit
|
||
$PROGRAM doctor --summary
|
||
$PROGRAM test dock
|
||
$PROGRAM upgrade
|
||
$PROGRAM apps
|
||
$PROGRAM app
|
||
$PROGRAM app chatgpt-desktop
|
||
EOF
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Guard: make sure we're in a git repo
|
||
# ----------------------------------------------------------------------------
|
||
require_git_repo() {
|
||
if ! git -C "$PANAMA_DIR" rev-parse --git-dir >/dev/null 2>&1; then
|
||
err "'$PANAMA_DIR' is not a git repository."
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: update
|
||
# ----------------------------------------------------------------------------
|
||
cmd_update() {
|
||
require_git_repo
|
||
cd "$PANAMA_DIR"
|
||
|
||
info "Panama repo: ${BOLD}${PANAMA_DIR}${RESET}"
|
||
|
||
# Any changes in the working tree? (modified, staged, or untracked)
|
||
if [[ -z "$(git status --porcelain)" ]]; then
|
||
info "Working tree is clean — pulling latest changes."
|
||
if git pull --ff-only; then
|
||
ok "Already in sync."
|
||
else
|
||
err "git pull failed."
|
||
exit 1
|
||
fi
|
||
return
|
||
fi
|
||
|
||
# Show what changed
|
||
header "Changed files"
|
||
git -c color.status=always status --short
|
||
|
||
header "Diff"
|
||
# Tracked changes (staged + unstaged) relative to the last commit.
|
||
git --no-pager -c color.diff=always diff HEAD
|
||
|
||
# Untracked files won't appear in 'git diff', so show them as new files.
|
||
local untracked
|
||
untracked=$(git ls-files --others --exclude-standard)
|
||
if [[ -n "$untracked" ]]; then
|
||
while IFS= read -r f; do
|
||
[[ -z "$f" ]] && continue
|
||
git --no-pager -c color.diff=always diff --no-index -- /dev/null "$f" || true
|
||
done <<< "$untracked"
|
||
fi
|
||
|
||
echo
|
||
if ! confirm "Commit these changes?"; then
|
||
warn "Aborted — no changes committed."
|
||
return
|
||
fi
|
||
|
||
# Commit message
|
||
local msg
|
||
printf '%s?%s Commit message: ' "${CYAN}${BOLD}" "$RESET"
|
||
read -r msg || true
|
||
if [[ -z "${msg// }" ]]; then
|
||
msg="Update $(date '+%Y-%m-%d %H:%M:%S')"
|
||
warn "No message given — using: ${BOLD}${msg}${RESET}"
|
||
fi
|
||
|
||
# Is the local branch up to date with its upstream?
|
||
info "Checking whether the repo is up to date..."
|
||
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
||
git fetch --quiet
|
||
local local_rev remote_rev base_rev
|
||
local_rev=$(git rev-parse @)
|
||
remote_rev=$(git rev-parse '@{u}')
|
||
base_rev=$(git merge-base @ '@{u}')
|
||
|
||
if [[ "$local_rev" == "$remote_rev" ]]; then
|
||
ok "Repo is up to date."
|
||
elif [[ "$local_rev" == "$base_rev" ]]; then
|
||
warn "Repo is behind upstream — stashing, pulling, then re-applying."
|
||
info "Stashing local changes..."
|
||
git stash push --include-untracked -m "panama-update-$(date +%s)" >/dev/null
|
||
|
||
if ! git pull --ff-only; then
|
||
err "git pull failed — restoring your changes."
|
||
git stash pop || true
|
||
exit 1
|
||
fi
|
||
|
||
info "Re-applying stashed changes..."
|
||
if ! git stash pop; then
|
||
err "Conflict while re-applying changes. Resolve it, then commit manually."
|
||
exit 1
|
||
fi
|
||
else
|
||
warn "Local branch has diverged from upstream — committing locally only."
|
||
fi
|
||
else
|
||
warn "No upstream configured for this branch — committing locally only."
|
||
fi
|
||
|
||
# Commit everything
|
||
info "Committing changes..."
|
||
git add -A
|
||
git commit -m "$msg"
|
||
ok "Committed: ${BOLD}${msg}${RESET}"
|
||
|
||
echo
|
||
if confirm "Push the changes now?"; then
|
||
info "Pushing..."
|
||
if git push; then
|
||
ok "Pushed to remote."
|
||
else
|
||
err "git push failed."
|
||
exit 1
|
||
fi
|
||
else
|
||
info "Done — changes committed locally but not pushed."
|
||
fi
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: edit
|
||
# ----------------------------------------------------------------------------
|
||
cmd_edit() {
|
||
if ! command -v nvim >/dev/null 2>&1; then
|
||
err "Neovim (nvim) is not installed or not on PATH."
|
||
exit 1
|
||
fi
|
||
info "Opening ${BOLD}${PANAMA_DIR}${RESET} in Neovim."
|
||
cd "$PANAMA_DIR"
|
||
exec nvim .
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: doctor
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The health check already exists and the installer already runs it; what it did
|
||
# not have was a way to reach it from a terminal. Everything is passed straight
|
||
# through, so --summary and anything added later work without this knowing about
|
||
# them.
|
||
cmd_doctor() {
|
||
local doctor="$PANAMA_DIR/config/dot/quickshell/scripts/panama-doctor"
|
||
if [[ ! -x "$doctor" ]]; then
|
||
err "panama-doctor is missing from $doctor"
|
||
exit 1
|
||
fi
|
||
exec "$doctor" "$@"
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: test
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The contracts are the main safety net in this repository and had no entry
|
||
# point: 121 executables with no runner and no mention in the README, which is
|
||
# most of the way to not having them.
|
||
#
|
||
# Each runs in its own process and a failure does not stop the rest, because the
|
||
# useful output is the whole list of what is broken rather than the first thing
|
||
# that broke. The exit code is what a caller can act on.
|
||
cmd_test() {
|
||
local pattern="${1:-}"
|
||
local -a suite=()
|
||
|
||
# Executables, plus the Python suites. Those are unittest files rather than
|
||
# executables, and collecting only what has the executable bit would skip them
|
||
# without saying so -- which is how all three came to be run by nothing at all.
|
||
# A runner with a blind spot is worse than no runner, because it reports PASS.
|
||
while IFS= read -r path; do
|
||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path")
|
||
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||
|
||
if (( ${#suite[@]} == 0 )); then
|
||
err "No contracts match '${pattern}'"
|
||
exit 1
|
||
fi
|
||
|
||
info "Running ${#suite[@]} contract(s)"
|
||
local -a failed=()
|
||
local path name
|
||
local -a runner
|
||
for path in "${suite[@]}"; do
|
||
name="${path#"$PANAMA_DIR"/tests/}"
|
||
if [[ "$path" == *_test.py ]]; then
|
||
runner=(python3 "$path")
|
||
else
|
||
runner=("$path")
|
||
fi
|
||
if "${runner[@]}" >/dev/null 2>&1; then
|
||
ok "$name"
|
||
else
|
||
err "$name"
|
||
failed+=("$name")
|
||
fi
|
||
done
|
||
|
||
header "Result"
|
||
if (( ${#failed[@]} == 0 )); then
|
||
ok "${#suite[@]} contract(s) passed"
|
||
return 0
|
||
fi
|
||
err "${#failed[@]} of ${#suite[@]} failed:"
|
||
printf ' %s\n' "${failed[@]}" >&2
|
||
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
|
||
return 1
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: upgrade
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# ./install is the upgrade path -- every stage is idempotent and re-running is
|
||
# the documented way to repair a machine. This only saves remembering where the
|
||
# repository lives.
|
||
cmd_upgrade() {
|
||
local installer="$PANAMA_DIR/install"
|
||
if [[ ! -x "$installer" ]]; then
|
||
err "The installer is missing from $installer"
|
||
exit 1
|
||
fi
|
||
info "Re-running ${BOLD}${installer}${RESET}"
|
||
cd "$PANAMA_DIR"
|
||
exec "$installer" "$@"
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: app
|
||
# ----------------------------------------------------------------------------
|
||
#
|
||
# The applications that neither dnf nor Flathub carries, built from source into
|
||
# a package dnf can still own and remove.
|
||
#
|
||
# Deliberately NOT part of ./install. A source build is slow, wants the network
|
||
# for the whole of it, and depends on an upstream that moves -- which is exactly
|
||
# the failure the interview exists to prevent: twenty minutes in, a prompt or an
|
||
# error, with nobody at the keyboard. Asking for one of these is a thing you do
|
||
# on purpose, and it is also the rebuild path when a new version ships.
|
||
#
|
||
# Nothing is pinned. Each build takes the current default branch and the current
|
||
# upstream release, and says so when it fails. A recorded version is a 404
|
||
# waiting to happen -- sunhat proved that three times over.
|
||
APPS_DIR="$PANAMA_DIR/setup/apps"
|
||
APPS_WORK="${XDG_CACHE_HOME:-$HOME/.cache}/panama/apps"
|
||
|
||
cmd_app() {
|
||
local name="${1:-}"
|
||
|
||
if [[ -z "$name" ]]; then
|
||
header "Applications"
|
||
printf 'Built from source, because no repository carries them.\n\n'
|
||
local file
|
||
for file in "$APPS_DIR"/*; do
|
||
[[ -f "$file" ]] || continue
|
||
local description=""
|
||
# shellcheck source=/dev/null
|
||
source "$file"
|
||
printf ' %s%-18s%s %s\n' "$GREEN" "$(basename "$file")" "$RESET" "$description"
|
||
done
|
||
printf '\nBuild one with: %s%s app <name>%s\n' "$BOLD" "$PROGRAM" "$RESET"
|
||
return 0
|
||
fi
|
||
|
||
local definition="$APPS_DIR/$name"
|
||
if [[ ! -f "$definition" ]]; then
|
||
err "No such application: '$name'"
|
||
printf "Run '%s app' to see what is available.\n" "$PROGRAM" >&2
|
||
exit 1
|
||
fi
|
||
|
||
local description="" repo=""
|
||
# shellcheck source=/dev/null
|
||
source "$definition"
|
||
[[ -n "$repo" ]] || { err "$name declares no repository"; exit 1; }
|
||
|
||
# The checkout lives in the cache because it is entirely rebuildable and
|
||
# should never be mistaken for something to keep. Existing checkouts are
|
||
# reset to upstream rather than merged: a local edit in a build tree is not
|
||
# something to preserve silently.
|
||
local tree="$APPS_WORK/$name"
|
||
if [[ -d "$tree/.git" ]]; then
|
||
info "Updating $name"
|
||
git -C "$tree" fetch --depth 1 origin HEAD || { err "Could not reach $repo"; exit 1; }
|
||
git -C "$tree" reset --hard FETCH_HEAD >/dev/null
|
||
else
|
||
info "Cloning $name"
|
||
mkdir -p "$APPS_WORK"
|
||
rm -rf "$tree"
|
||
git clone --depth 1 "$repo" "$tree" || { err "Could not clone $repo"; exit 1; }
|
||
fi
|
||
|
||
info "Building ${BOLD}${name}${RESET} — this takes a while and needs the network"
|
||
if ( cd "$tree" && build ); then
|
||
ok "$name installed"
|
||
printf 'Built from %s\n' "$(git -C "$tree" rev-parse --short HEAD)"
|
||
else
|
||
err "$name failed to build"
|
||
printf 'The tree is left at %s so the failure can be read.\n' "$tree" >&2
|
||
printf 'This builds against upstream HEAD, so a break there breaks this.\n' >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Command: apps
|
||
# ----------------------------------------------------------------------------
|
||
# The optional applications, chosen a few at a time rather than all at once.
|
||
#
|
||
# ./install offers the same catalog as whole categories, because during a first
|
||
# install you want coarse and fast. This is the other end of it: pick a
|
||
# category, then tick the applications inside, and install just those. Both read
|
||
# setup/lib/extras-catalog, so neither can drift from the other.
|
||
#
|
||
# Source-built applications appear here too, as their own category. They are not
|
||
# part of ./install for good reason -- a build is slow, wants the network
|
||
# throughout, and can fail on an upstream that moved -- but "choose, then
|
||
# install" is exactly the right shape for them, which is what this is.
|
||
cmd_apps() {
|
||
if ! command -v gum >/dev/null 2>&1; then
|
||
err "gum is not installed. It is in setup/packages/initial-packages; run 'panama upgrade'."
|
||
exit 1
|
||
fi
|
||
|
||
# shellcheck source=../setup/lib/extras-catalog
|
||
source "$PANAMA_DIR/setup/lib/extras-catalog"
|
||
|
||
local extras_dir="$PANAMA_DIR/setup/packages/extras"
|
||
local apps_dir="$PANAMA_DIR/setup/apps"
|
||
|
||
local -a categories=()
|
||
mapfile -t categories < <(catalog_categories "$extras_dir")
|
||
[[ -d "$apps_dir" ]] && categories+=("built from source")
|
||
|
||
(( ${#categories[@]} > 0 )) || { err "No application categories found."; exit 1; }
|
||
|
||
local category
|
||
category="$(gum choose --header "Which kind of application?" "${categories[@]}")" || return 0
|
||
[[ -n "$category" ]] || return 0
|
||
|
||
if [[ "$category" == "built from source" ]]; then
|
||
cmd_apps_source "$apps_dir"
|
||
return
|
||
fi
|
||
|
||
local file="$extras_dir/$category"
|
||
local -a labels=() targets=()
|
||
local target label marker
|
||
while IFS=$'\t' read -r target label; do
|
||
[[ -n "$target" ]] || continue
|
||
# Marked, not hidden: reinstalling what is present is harmless, but a menu
|
||
# that silently omits it leaves you wondering where it went.
|
||
if catalog_installed "$target"; then marker=" (installed)"; else marker=""; fi
|
||
targets+=("$target")
|
||
labels+=("$label$marker")
|
||
done < <(catalog_entries "$file")
|
||
|
||
local chosen
|
||
chosen="$(gum choose --no-limit --header "$category — space to select, enter to accept" "${labels[@]}")" || return 0
|
||
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
|
||
|
||
# Back from labels to install targets, then out to everything each one carries.
|
||
local -a install_targets=()
|
||
local pick i
|
||
while IFS= read -r pick; do
|
||
[[ -n "$pick" ]] || continue
|
||
for i in "${!labels[@]}"; do
|
||
if [[ "${labels[$i]}" == "$pick" ]]; then
|
||
mapfile -t -O "${#install_targets[@]}" install_targets < <(catalog_targets "$file" "${targets[$i]}")
|
||
break
|
||
fi
|
||
done
|
||
done <<< "$chosen"
|
||
|
||
local -a dnf_packages=() flatpak_ids=()
|
||
for target in "${install_targets[@]}"; do
|
||
if [[ "$target" == flatpak:* ]]; then flatpak_ids+=("${target#flatpak:}"); else dnf_packages+=("$target"); fi
|
||
done
|
||
|
||
header "About to install"
|
||
(( ${#dnf_packages[@]} > 0 )) && printf ' dnf %s\n' "${dnf_packages[*]}"
|
||
(( ${#flatpak_ids[@]} > 0 )) && printf ' flatpak %s\n' "${flatpak_ids[*]}"
|
||
echo
|
||
confirm "Install these?" || { warn "Nothing installed."; return 0; }
|
||
|
||
if (( ${#dnf_packages[@]} > 0 )); then
|
||
info "Installing ${#dnf_packages[@]} package(s) with dnf"
|
||
sudo dnf install -y "${dnf_packages[@]}" || warn "Some packages did not install"
|
||
fi
|
||
if (( ${#flatpak_ids[@]} > 0 )); then
|
||
info "Installing ${#flatpak_ids[@]} flatpak(s)"
|
||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo >/dev/null
|
||
sudo flatpak install -y flathub "${flatpak_ids[@]}" || warn "Some flatpaks did not install"
|
||
fi
|
||
ok "Done."
|
||
}
|
||
|
||
# The source-built applications, offered by the same two-step flow and handed to
|
||
# the existing `panama app` so there is one build path, not two.
|
||
cmd_apps_source() {
|
||
local apps_dir="$1"
|
||
local -a names=()
|
||
mapfile -t names < <(for f in "$apps_dir"/*; do [[ -f "$f" ]] && basename "$f"; done)
|
||
(( ${#names[@]} > 0 )) || { err "No applications in $apps_dir."; return 1; }
|
||
|
||
local chosen
|
||
chosen="$(gum choose --no-limit --header "Built from source — these take a while" "${names[@]}")" || return 0
|
||
[[ -n "$chosen" ]] || { info "Nothing selected."; return 0; }
|
||
|
||
local name
|
||
while IFS= read -r name; do
|
||
[[ -n "$name" ]] || continue
|
||
header "Building $name"
|
||
cmd_app "$name" || warn "$name did not build"
|
||
done <<< "$chosen"
|
||
}
|
||
|
||
# ----------------------------------------------------------------------------
|
||
# Dispatcher
|
||
# ----------------------------------------------------------------------------
|
||
main() {
|
||
local cmd="${1:-}"
|
||
case "$cmd" in
|
||
update) shift; cmd_update "$@" ;;
|
||
edit) shift; cmd_edit "$@" ;;
|
||
doctor) shift; cmd_doctor "$@" ;;
|
||
test) shift; cmd_test "$@" ;;
|
||
upgrade) shift; cmd_upgrade "$@" ;;
|
||
app) shift; cmd_app "$@" ;;
|
||
apps) shift; cmd_apps "$@" ;;
|
||
help|-h|--help|"") usage ;;
|
||
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
||
*)
|
||
err "Unknown command: '$cmd'"
|
||
echo
|
||
usage
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
main "$@"
|