mirror of
https://github.com/getumbrel/umbrel.git
synced 2026-10-12 02:14:08 +00:00
595 lines
16 KiB
Bash
Executable File
595 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
VERSION="0.0.3"
|
|
|
|
UMBREL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
|
|
USER_FILE="${UMBREL_ROOT}/db/user.json"
|
|
|
|
APP_PROXY_SERVICE_NAME="app_proxy"
|
|
REMOTE_TOR_ACCESS="false"
|
|
if [[ -f "${USER_FILE}" ]]; then
|
|
REMOTE_TOR_ACCESS=$(cat "${USER_FILE}" | jq 'has("remoteTorAccess") and .remoteTorAccess')
|
|
fi
|
|
|
|
show_help() {
|
|
cat << EOF
|
|
CLI (v${VERSION}) for managing Umbrel apps
|
|
|
|
Usage: app <command> <app> [<arguments>]
|
|
|
|
Commands:
|
|
install Pulls down images for an app and starts it
|
|
uninstall Removes images and destroys all data for an app
|
|
reinstall Calls 'uninstall', followed by 'install' for an app
|
|
start Starts an installed app
|
|
stop Stops an installed app
|
|
restart Restarts an installed app
|
|
compose Passes all arguments to docker-compose
|
|
ls-installed Lists installed apps
|
|
EOF
|
|
}
|
|
|
|
check_dependencies () {
|
|
for cmd in "$@"; do
|
|
if ! command -v $cmd >/dev/null 2>&1; then
|
|
>&2 echo "This script requires \"${cmd}\" to be installed"
|
|
exit 1
|
|
fi
|
|
done
|
|
}
|
|
|
|
list_installed_apps() {
|
|
cat "${USER_FILE}" 2> /dev/null | jq -r 'if has("installedApps") then .installedApps else [] end | join("\n")' || true
|
|
}
|
|
|
|
# Deterministically derives 128 bits of cryptographically secure entropy
|
|
derive_entropy () {
|
|
# Make sure we use the seed from the real Umbrel installation if this is
|
|
# an OTA update.
|
|
SEED_FILE="${UMBREL_ROOT}/db/umbrel-seed/seed"
|
|
if [[ ! -f "${SEED_FILE}" ]] && [[ -f "${UMBREL_ROOT}/../.umbrel" ]]; then
|
|
SEED_FILE="${UMBREL_ROOT}/../db/umbrel-seed/seed"
|
|
fi
|
|
|
|
identifier="${1}"
|
|
umbrel_seed=$(cat "${SEED_FILE}") || true
|
|
|
|
if [[ -z "$umbrel_seed" ]] || [[ -z "$identifier" ]]; then
|
|
>&2 echo "Missing derivation parameter, this is unsafe, exiting."
|
|
exit 1
|
|
fi
|
|
|
|
# We need `sed 's/^.* //'` to trim the "(stdin)= " prefix from some versions of openssl
|
|
printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${umbrel_seed}" | sed 's/^.* //'
|
|
}
|
|
|
|
# Setup env. for this context for a given app
|
|
source_app() {
|
|
local -r app="${1}"
|
|
|
|
local -r app_domain="$(hostname -s 2>/dev/null || echo "umbrel").local"
|
|
local -r app_entropy_identifier="app-${app}-seed"
|
|
|
|
# Load in existing Umbrel .env
|
|
# So that apps in their exports.sh can access
|
|
# e.g. $TOR_PROXY_IP, $TOR_PROXY_PORT
|
|
[[ -f "${UMBREL_ROOT}/.env" ]] && . "${UMBREL_ROOT}/.env"
|
|
|
|
export NETWORK_IP="${NETWORK_IP}"
|
|
|
|
# Set other useful vars. used in exports
|
|
export DEVICE_HOSTNAME="$(cat /proc/sys/kernel/hostname 2>/dev/null || echo "umbrel")"
|
|
export DEVICE_DOMAIN_NAME="${DEVICE_HOSTNAME}.local"
|
|
|
|
# Set env using all installed apps exports.sh
|
|
# Do this first so that no app exports can
|
|
# Override any app specific exports defined below
|
|
EXPORTS_TOR_DATA_DIR="${UMBREL_ROOT}/tor/data"
|
|
|
|
APPS_TO_SOURCE="$(list_installed_apps)"
|
|
|
|
# $app might not be in the 'installed apps list' yet
|
|
# i.e. If it is currently being installed
|
|
# So we'll add it to the list of apps that will be 'sourced'
|
|
if ! echo "${APPS_TO_SOURCE}" | grep --quiet "^${app}$"; then
|
|
APPS_TO_SOURCE="${APPS_TO_SOURCE}"$'\n'"${app}"
|
|
fi
|
|
|
|
for EXPORTS_APP_ID in $APPS_TO_SOURCE; do
|
|
EXPORTS_APP_DIR="${UMBREL_ROOT}/app-data/${EXPORTS_APP_ID}"
|
|
EXPORTS_APP_FILE="${EXPORTS_APP_DIR}/exports.sh"
|
|
EXPORTS_APP_DATA_DIR="${EXPORTS_APP_DIR}/data"
|
|
|
|
[[ -f "${EXPORTS_APP_FILE}" ]] && . "${EXPORTS_APP_FILE}" || true
|
|
done
|
|
|
|
# App specific exports
|
|
export APP_ID="${app}"
|
|
export APP_MANIFEST_FILE="${app_data_dir}/umbrel-app.yml"
|
|
export APP_VERSION=$(cat "${APP_MANIFEST_FILE}" | yq '.version')
|
|
|
|
# This provides the app proxy with context of the app
|
|
export APP_PROXY_HOSTNAME="app_proxy_${app}"
|
|
export APP_PROXY_PORT=$(cat "${APP_MANIFEST_FILE}" | yq '.port')
|
|
|
|
export APP_DATA_DIR="${app_data_dir}"
|
|
export APP_DOMAIN="${app_domain}"
|
|
export APP_HIDDEN_SERVICE="not-enabled.onion"
|
|
if [[ "${REMOTE_TOR_ACCESS}" == "true" ]]; then
|
|
export APP_HIDDEN_SERVICE="$(cat "${app_hidden_service_file}" 2>/dev/null || echo "notyetset.onion")"
|
|
fi
|
|
export APP_SEED=$(derive_entropy "${app_entropy_identifier}")
|
|
export APP_PASSWORD=$(derive_entropy "${app_entropy_identifier}-APP_PASSWORD")
|
|
|
|
# Tor specific exports
|
|
export TOR_DATA_DIR="${UMBREL_ROOT}/tor/data"
|
|
export TOR_ENTRYPOINT_SCRIPT="${UMBREL_ROOT}/scripts/support/tor-entrypoint.sh"
|
|
export TOR_HS_APP_DIR="/data/app-${app}"
|
|
export TOR_HS_PORTS="80:${APP_PROXY_HOSTNAME}:${APP_PROXY_PORT}"
|
|
|
|
tor_extra_hs_varname=$(echo "APP_${APP_ID^^}_TOR_HS_EXTRA_PORTS" | tr '-' '_')
|
|
tor_hs_extra_ports="${!tor_extra_hs_varname:-}"
|
|
|
|
if [[ ! -z "${tor_hs_extra_ports}" ]]; then
|
|
export TOR_HS_PORTS="${TOR_HS_PORTS} ${tor_hs_extra_ports}"
|
|
fi
|
|
|
|
# Other
|
|
export UMBREL_ROOT
|
|
}
|
|
|
|
# Check dependencies
|
|
check_dependencies docker-compose jq yq openssl envsubst
|
|
|
|
if [ -z ${1+x} ]; then
|
|
command=""
|
|
else
|
|
command="$1"
|
|
fi
|
|
|
|
# Lists installed apps
|
|
if [[ "$command" = "ls-installed" ]]; then
|
|
list_installed_apps
|
|
|
|
exit
|
|
fi
|
|
|
|
if [ -z ${2+x} ]; then
|
|
show_help
|
|
exit 1
|
|
else
|
|
app="$2"
|
|
|
|
repo=$(cat "${USER_FILE}" 2> /dev/null | jq -r ".appOrigin.\"${app}\"" || true)
|
|
repo_path=$("${UMBREL_ROOT}/scripts/repo" "path" "${repo}")
|
|
app_repo_dir="${repo_path}/${app}"
|
|
app_data_dir="${UMBREL_ROOT}/app-data/${app}"
|
|
|
|
app_hidden_service_file="${UMBREL_ROOT}/tor/data/app-${app}/hostname"
|
|
|
|
if [[ "${app}" == "installed" ]]; then
|
|
for app in $(list_installed_apps); do
|
|
if [[ "${app}" != "" ]]; then
|
|
"${0}" "${1}" "${app}" "${@:3}" &
|
|
fi
|
|
done
|
|
wait
|
|
exit
|
|
fi
|
|
|
|
if [[ -z "${app}" ]]; then
|
|
>&2 echo "Error: \"${app}\" is not a valid app"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
if [ -z ${3+x} ]; then
|
|
args=""
|
|
else
|
|
args="${@:3}"
|
|
fi
|
|
|
|
execute_hook() {
|
|
local -r app="${1}"
|
|
local -r name="${2}"
|
|
|
|
local -r app_hooks_dir="${UMBREL_ROOT}/app-data/${app}/hooks"
|
|
local -r hook="${app_hooks_dir}/${name}"
|
|
|
|
if [[ -x "${hook}" ]]; then
|
|
echo "Executing hook: ${hook}"
|
|
# Swallow non-zero exit code
|
|
"${hook}" || true
|
|
fi
|
|
}
|
|
|
|
compose() {
|
|
local -r app="${1}"
|
|
shift
|
|
|
|
# Source env.
|
|
source_app "${app}"
|
|
|
|
# Define support compose files
|
|
local -r app_proxy_compose_file="${UMBREL_ROOT}/scripts/support/docker-compose.app_proxy.yml"
|
|
local -r tor_compose_file="${UMBREL_ROOT}/scripts/support/docker-compose.tor.yml"
|
|
local -r common_compose_file="${UMBREL_ROOT}/scripts/support/docker-compose.common.yml"
|
|
local -r app_compose_file="${app_data_dir}/docker-compose.yml"
|
|
|
|
local -r umbrel_env_file="${UMBREL_ROOT}/.env"
|
|
|
|
# We need to use the proxy compose file first
|
|
# To allow vars. in the app's compose file to override variables
|
|
compose_files=()
|
|
|
|
# Detect if the 'app_proxy' service has been defined
|
|
# In the app's docker-compose file
|
|
has_app_proxy_service=$(cat "${app_compose_file}" | yq ".services | has(\"${APP_PROXY_SERVICE_NAME}\")")
|
|
|
|
if [[ "${has_app_proxy_service}" == "true" ]]; then
|
|
compose_files+=( "--file" "${app_proxy_compose_file}" )
|
|
fi
|
|
|
|
# If remote Tor access is enabled
|
|
# Then include a compose file for Tor
|
|
if [[ "${REMOTE_TOR_ACCESS}" == "true" ]]; then
|
|
compose_files+=( "--file" "${tor_compose_file}" )
|
|
fi
|
|
|
|
# Add app's compose file last so that it can override
|
|
# Any of the other compose files
|
|
compose_files+=( "--file" "${common_compose_file}" )
|
|
compose_files+=( "--file" "${app_compose_file}" )
|
|
|
|
# Merge compose files and args. passed into 'compose'
|
|
compose_args=("${compose_files[@]}" "${@}")
|
|
|
|
docker-compose \
|
|
--env-file "${umbrel_env_file}" \
|
|
--project-name "${app}" \
|
|
"${compose_args[@]}"
|
|
}
|
|
|
|
update_installed_apps() {
|
|
local -r action="${1}"
|
|
local -r app="${2}"
|
|
local -r repo="${3:-null}"
|
|
|
|
while ! (set -o noclobber; echo "$$" > "${USER_FILE}.lock") 2> /dev/null; do
|
|
echo "Waiting for JSON lock to be released for ${app} update..."
|
|
sleep 1
|
|
done
|
|
# This will cause the lock-file to be deleted in case of a
|
|
# premature exit.
|
|
trap "rm -f "${USER_FILE}.lock"; exit $?" INT TERM EXIT
|
|
|
|
[[ "${action}" == "add" ]] && operator="+" || operator="-"
|
|
updated_json=$(cat "${USER_FILE}" | jq ".installedApps |= (. ${operator} [\"${app}\"] | unique)")
|
|
echo "${updated_json}" > "${USER_FILE}"
|
|
|
|
if [[ "${action}" == "add" ]]; then
|
|
updated_json=$(cat "${USER_FILE}" | jq ".appOrigin |= (. ${operator} {\"${app}\":\"${repo}\"})")
|
|
else
|
|
updated_json=$(cat "${USER_FILE}" | jq "del(.appOrigin.\"${app}\")")
|
|
fi
|
|
echo "${updated_json}" > "${USER_FILE}"
|
|
|
|
rm -f "${USER_FILE}.lock"
|
|
}
|
|
|
|
template_app() {
|
|
local -r app="${1}"
|
|
|
|
# Loop over all templates within app and populate them
|
|
APP_TEMPLATE_FILES="${app_data_dir}/*.template"
|
|
|
|
shopt -s nullglob
|
|
for APP_TEMPLATE_INPUT_FILE in $APP_TEMPLATE_FILES; do
|
|
# Output filename is the same as input with .template stripped off
|
|
APP_TEMPLATE_OUTPUT_FILE="${APP_TEMPLATE_INPUT_FILE%.*}"
|
|
|
|
# First we'll copy the file so we ensure the output
|
|
# has the same fs permissions as the input
|
|
cp --archive "${APP_TEMPLATE_INPUT_FILE}" "${APP_TEMPLATE_OUTPUT_FILE}"
|
|
cat "${APP_TEMPLATE_INPUT_FILE}" | envsubst > "${APP_TEMPLATE_OUTPUT_FILE}"
|
|
done
|
|
}
|
|
|
|
copy_app_files() {
|
|
local -r files_to_copy="${1}"
|
|
|
|
for filename in $files_to_copy; do
|
|
APP_FILES="${app_repo_dir}/${filename}"
|
|
|
|
for app_file in $APP_FILES; do
|
|
if [[ -f "${app_file}" ]] || [[ -d "${app_file}" ]]; then
|
|
cp --archive "${app_file}" "${app_data_dir}"
|
|
fi
|
|
done
|
|
done
|
|
}
|
|
|
|
wait_for_tor_hs() {
|
|
local -r app="${1}"
|
|
|
|
# Check if the app's hidden service hostname
|
|
# Has been already generated and exit early
|
|
if [[ -f "${app_hidden_service_file}" ]]; then
|
|
return
|
|
fi
|
|
|
|
# Check that the app has the App Proxy service defined
|
|
local -r app_compose_file="${app_data_dir}/docker-compose.yml"
|
|
has_app_proxy_service=$(cat "${app_compose_file}" | yq ".services | has(\"${APP_PROXY_SERVICE_NAME}\")")
|
|
|
|
if [[ "${has_app_proxy_service}" == "false" ]]; then
|
|
echo
|
|
>&2 echo "Warning: \"${app}\" has no '${APP_PROXY_SERVICE_NAME}' defined"
|
|
>&2 echo " \"${app}\" needs this to generate Tor HS"
|
|
echo
|
|
return
|
|
fi
|
|
|
|
# If a tor service will start
|
|
# and there is no existing tor hs hostname
|
|
# Let's allow 10 seconds to generate it and then start the app
|
|
if [[ "${REMOTE_TOR_ACCESS}" == "true" ]]; then
|
|
echo "Generating hidden services for ${app}..."
|
|
# We must first start the App Proxy
|
|
# So that it's hostname is resolvable by Tor
|
|
# More details here: https://github.com/torproject/tor/blob/01bda6c23f58947ad1e20ea6367a5c260f53dfab/src/feature/hs/hs_common.c#L743
|
|
# And here: https://github.com/torproject/tor/blob/22552ad88e1e95ef9d2c6655c7602b7b25836075/src/lib/net/resolve.c#L297
|
|
# Otherwise Tor will throw this error:
|
|
# Unparseable address in hidden service port configuration.
|
|
compose "${app}" up --detach app_proxy
|
|
compose "${app}" up --detach tor_server
|
|
|
|
for attempt in $(seq 1 100); do
|
|
if [[ -f "${app_hidden_service_file}" ]]; then
|
|
echo "Hidden service file created successfully!"
|
|
break
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
|
|
if [[ ! -f "${app_hidden_service_file}" ]]; then
|
|
echo "Hidden service file wasn't created"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
start_app() {
|
|
local -r app="${1}"
|
|
|
|
# Source env.
|
|
source_app "${app}"
|
|
|
|
# Now apply templates
|
|
template_app "${app}"
|
|
|
|
# Wait for Tor's HS hostname to exist
|
|
wait_for_tor_hs "${app}"
|
|
|
|
execute_hook "${app}" "pre-start"
|
|
|
|
# Start all the app's containers
|
|
compose "${app}" up --detach
|
|
|
|
execute_hook "${app}" "post-start"
|
|
}
|
|
|
|
# Check that the app is installed
|
|
must_be_installed_guard() {
|
|
if ! list_installed_apps | grep --quiet "^${app}$"; then
|
|
>&2 echo "Error: app \"${app}\" is not installed yet"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Pulls down images for an app and starts it
|
|
if [[ "$command" = "install" ]]; then
|
|
|
|
repo=$("${UMBREL_ROOT}/scripts/repo" "locate" "${app}")
|
|
|
|
if [[ -z "${repo}" ]]; then
|
|
>&2 echo "Error: \"${app}\" not found in any local app repo"
|
|
exit 1
|
|
fi
|
|
|
|
app_repo_dir=$("${UMBREL_ROOT}/scripts/repo" "path" "${repo}")
|
|
app_repo_dir="${app_repo_dir}/${app}"
|
|
|
|
echo "Installing '${app}' from: ${repo}"
|
|
|
|
echo "Setting up data dir for app ${app}..."
|
|
mkdir -p "${app_data_dir}"
|
|
|
|
# Copy all app files
|
|
rsync --archive --verbose --exclude ".gitkeep" "${app_repo_dir}/." "${app_data_dir}"
|
|
|
|
execute_hook "${app}" "pre-install"
|
|
|
|
# Source env.
|
|
source_app "${app}"
|
|
|
|
# Now apply templates
|
|
template_app "${app}"
|
|
|
|
echo "Pulling images for app ${app}..."
|
|
compose "${app}" pull
|
|
|
|
if [[ "$*" != *"--skip-start"* ]]; then
|
|
echo "Starting app ${app}..."
|
|
start_app "${app}"
|
|
fi
|
|
|
|
echo "Saving app ${app} in DB..."
|
|
update_installed_apps add "${app}" "${repo}"
|
|
|
|
execute_hook "${app}" "post-install"
|
|
|
|
echo "Successfully installed app ${app}"
|
|
exit
|
|
fi
|
|
|
|
# Removes images and destroys all data for an app
|
|
if [[ "$command" = "uninstall" ]]; then
|
|
|
|
must_be_installed_guard
|
|
|
|
execute_hook "${app}" "pre-uninstall"
|
|
|
|
# If a post uninstal hook exists
|
|
# Then make a copy before it's deleted below
|
|
app_hooks_dir="${UMBREL_ROOT}/app-data/${app}/hooks"
|
|
post_uninstall_app_hook="${app_hooks_dir}/post-uninstall"
|
|
if [[ -x "${post_uninstall_app_hook}" ]]; then
|
|
temp_post_uninstall_app_hook="/tmp/${app}-post-uninstall"
|
|
|
|
cp --archive "${post_uninstall_app_hook}" "${temp_post_uninstall_app_hook}"
|
|
|
|
post_uninstall_app_hook="${temp_post_uninstall_app_hook}"
|
|
else
|
|
post_uninstall_app_hook=""
|
|
fi
|
|
|
|
echo "Removing images for app ${app}..."
|
|
compose "${app}" down --rmi all --remove-orphans
|
|
|
|
echo "Deleting app data for app ${app}..."
|
|
if [[ -d "${app_data_dir}" ]]; then
|
|
rm -rf "${app_data_dir}"
|
|
fi
|
|
|
|
echo "Removing app ${app} from DB..."
|
|
update_installed_apps remove "${app}"
|
|
|
|
if [[ ! -z "${post_uninstall_app_hook}" ]]; then
|
|
"${post_uninstall_app_hook}" || true
|
|
|
|
rm -rf "${post_uninstall_app_hook}"
|
|
fi
|
|
|
|
echo "Successfully uninstalled app ${app}"
|
|
exit
|
|
fi
|
|
|
|
# Stops an installed app
|
|
if [[ "$command" = "stop" ]]; then
|
|
|
|
must_be_installed_guard
|
|
|
|
execute_hook "${app}" "pre-stop"
|
|
|
|
echo "Stopping app ${app}..."
|
|
compose "${app}" rm --force --stop
|
|
|
|
execute_hook "${app}" "post-stop"
|
|
|
|
exit
|
|
fi
|
|
|
|
if [[ "$command" = "reinstall" ]]; then
|
|
|
|
"${0}" "uninstall" "${app}"
|
|
|
|
echo
|
|
"${0}" "install" "${app}"
|
|
|
|
exit
|
|
fi
|
|
|
|
# Starts an installed app
|
|
if [[ "$command" = "start" ]]; then
|
|
|
|
must_be_installed_guard
|
|
|
|
echo "Starting app ${app}..."
|
|
start_app "${app}"
|
|
|
|
exit
|
|
fi
|
|
|
|
# Restarts an installed app
|
|
if [[ "$command" = "restart" ]]; then
|
|
|
|
"${0}" "stop" "${app}"
|
|
|
|
"${0}" "start" "${app}"
|
|
|
|
exit
|
|
fi
|
|
|
|
# Update an installed app
|
|
if [[ "$command" = "update" ]]; then
|
|
|
|
must_be_installed_guard
|
|
|
|
# Check that the app folder still exists
|
|
# Within the associated local app repo
|
|
if [[ ! -d "${app_repo_dir}" ]]; then
|
|
>&2 echo "Error: Local app repo no longer exists for ${app}"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Updating '${app}' from: ${repo}"
|
|
# Save current images to clean up later
|
|
app_compose_file="${app_data_dir}/docker-compose.yml"
|
|
app_old_images=$(yq e '.services | map(select(.image != null)) | .[].image' "${app_compose_file}")
|
|
|
|
if [[ "$*" != *"--skip-stop"* ]]; then
|
|
"${0}" "stop" "${app}"
|
|
fi
|
|
|
|
execute_hook "${app}" "pre-update"
|
|
|
|
# App updates will only copy files from this whitelist:
|
|
UPDATE_FILES_WHITELIST_PRE="docker-compose.yml *.template exports.sh torrc hooks"
|
|
|
|
# We copy umbrel-app.yml after the app has started
|
|
# That way the frontend knows the update has finished
|
|
# And the app is running again
|
|
UPDATE_FILES_WHITELIST_POST="umbrel-app.yml"
|
|
|
|
copy_app_files "${UPDATE_FILES_WHITELIST_PRE}"
|
|
|
|
# Ensure remaining files are copied in case of unexpected exit
|
|
trap "copy_app_files "${UPDATE_FILES_WHITELIST_POST}"; exit $?" INT TERM EXIT
|
|
|
|
# Source env. after new exports.sh is copied (done above via 'copy_app_files')
|
|
source_app "${app}"
|
|
|
|
# Now apply templates
|
|
template_app "${app}"
|
|
|
|
echo "Pulling images for app ${app}..."
|
|
compose "${app}" pull
|
|
|
|
# Copy remaining files to mark update as complete
|
|
copy_app_files "${UPDATE_FILES_WHITELIST_POST}"
|
|
|
|
if [[ "$*" != *"--skip-start"* ]]; then
|
|
"${0}" "start" "${app}"
|
|
# Remove any old images we don't need anymore
|
|
docker rmi $app_old_images || true
|
|
fi
|
|
|
|
execute_hook "${app}" "post-update"
|
|
|
|
exit
|
|
fi
|
|
|
|
# Passes all arguments to docker-compose
|
|
if [[ "$command" = "compose" ]]; then
|
|
|
|
compose "${app}" ${args}
|
|
|
|
exit
|
|
fi
|
|
|
|
# If we get here it means no valid command was supplied
|
|
# Show help and exit
|
|
show_help
|
|
exit 1
|