TheTVDB writes its own configuration file and its serializer omits a null-valued setting entirely, so a fresh install has no <ApiKey> element to fill, under a root element called <PluginConfiguration>. xml_secret's fallback insertion was hardcoded to </Configuration>, matched nothing, and install refused with "no place to write <ApiKey>" — measured on Dankserver 2026-11-02, where the TVDB key has been present in the secret file since October and has never been installable. The anchor now comes from the document's own root, so both shapes Jellyfin actually produces work (the core plugins' <Configuration> and a plugin's <PluginConfiguration>), and the refusal is kept for a file with no root at all. Tests: the plugin-written shape is red before this commit and green after; every other setting in the file is asserted preserved.
463 lines
17 KiB
Bash
Executable File
463 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Metadata-provider keys for Jellyfin on Dankserver, placed from the service's
|
|
# host env file with the plugin artifact pinned by content hash.
|
|
#
|
|
# Jellyfin 12 ships TMDb, OMDb, StudioImages and MusicBrainz as CORE plugins:
|
|
# they have no DLL, only a configuration file under the data root, and that
|
|
# file is created by the running container. So the fleet's provider state lives
|
|
# in two places the repo does not track —
|
|
#
|
|
# plugins/configurations/Jellyfin.Plugin.Tmdb.xml <TmdbApiKey /> (empty = keyless)
|
|
# plugins/configurations/Jellyfin.Plugin.Tvdb.xml <ApiKey /> (needs the plugin)
|
|
#
|
|
# and both are owned by the container user. This tool is the only thing that
|
|
# writes them: it is declarative, idempotent, and it never prints a key, so it
|
|
# is safe to run while someone watches.
|
|
#
|
|
# Keys never enter git and never enter argv (a value in argv is world-readable
|
|
# through /proc/<pid>/cmdline). They live in the per-service host env file that
|
|
# is the fleet's convention for container credentials (grafana, caddy, pihole,
|
|
# copyparty, romm, seerr), mode 600:
|
|
#
|
|
# /home/dank/Containers/jellyfin/provider-keys
|
|
# TMDB_API_KEY=<key>
|
|
# TVDB_API_KEY=<key>
|
|
# TVDB_PIN=<pin> # optional: only a subscriber-shaped TVDB key
|
|
# # needs one; a licensed key does not
|
|
#
|
|
# TVDB itself is not core in 12 (the metadata provider was cut in 10.9), so it
|
|
# is installed from the official catalog, pinned three ways: sha256 of the
|
|
# artifact, the plugin's own guid, and its version. A matching sha proves only
|
|
# that the bytes are the bytes fetched — the guid check is what says it is the
|
|
# official plugin, the version check is what says it is the one this file was
|
|
# reviewed against.
|
|
#
|
|
# Subcommands
|
|
# plan print the resolved shape and every pending change, touch nothing
|
|
# install fetch+verify the plugin, then write the keys (backing up first)
|
|
# verify prove the plugin and both keys are in place, and that no library
|
|
# is a silent file index (providers off / image fetchers empty)
|
|
#
|
|
# Nothing here restarts Jellyfin: the deployer owns that
|
|
# (`scripts/deploy.sh dankserver --service jellyfin`), and the plugin is read at
|
|
# start-up, so a key written while the server runs is not live until then.
|
|
set -euo pipefail
|
|
|
|
# Jellyfin's data root on Dankserver (the rootless quadlet's config volume).
|
|
JF_ROOT="${JF_ROOT:-/home/dank/Containers/jellyfin/config}"
|
|
PROV_KEYS="${PROV_KEYS:-/home/dank/Containers/jellyfin/provider-keys}"
|
|
TRASH_DIR="${TRASH_DIR:-$HOME/.AGENTTRASH/$(date +%Y%m%d)-jellyfin-providers}"
|
|
|
|
PLUGINS="$JF_ROOT/plugins"
|
|
CONF="$PLUGINS/configurations"
|
|
TMDB_XML="$CONF/Jellyfin.Plugin.Tmdb.xml"
|
|
TVDB_XML="$CONF/Jellyfin.Plugin.Tvdb.xml"
|
|
|
|
# repo.jellyfin.org catalog entry for TheTVDB (owner: jellyfin), version 24.0.0.0,
|
|
# targetAbi 12.0.0.0 — published 2026-09-09, verified against Jellyfin 12.4.0.
|
|
TVDB_PLUGIN_URL="${TVDB_PLUGIN_URL:-https://repo.jellyfin.org/files/plugin/thetvdb/thetvdb_24.0.0.0.zip}"
|
|
TVDB_PLUGIN_SHA256="${TVDB_PLUGIN_SHA256:-6302fba3857bdde8d186dd099fac09ca43247041f531a528a2c788add70a5319}"
|
|
TVDB_PLUGIN_GUID="${TVDB_PLUGIN_GUID:-a677c0da-fac5-4cde-941a-7134223f14c8}"
|
|
TVDB_PLUGIN_VERSION="${TVDB_PLUGIN_VERSION:-24.0.0.0}"
|
|
PLUGIN_DIR="thetvdb_${TVDB_PLUGIN_VERSION}"
|
|
TVDB_DLL="Jellyfin.Plugin.Tvdb.dll"
|
|
|
|
die() {
|
|
printf 'jellyfin-providers: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
printf 'usage: %s plan|install|verify\n' "$0" >&2
|
|
exit 2
|
|
}
|
|
|
|
# check_keys — refuse anything that is not a readable private file. An open
|
|
# mode is an error, not a warning: a provider key readable by every user on the
|
|
# box is the failure this check exists to catch.
|
|
check_keys() {
|
|
[ -f "$PROV_KEYS" ] ||
|
|
die "$PROV_KEYS not found (create it with TMDB_API_KEY= and TVDB_API_KEY=, then chmod 600)"
|
|
local mode
|
|
mode="$(stat -c '%a' "$PROV_KEYS")"
|
|
case "$mode" in
|
|
600 | 400) ;;
|
|
*) die "$PROV_KEYS is mode $mode; provider keys must be 600" ;;
|
|
esac
|
|
}
|
|
|
|
# keys_value <VAR> — the value of one variable in the env file, or nothing.
|
|
keys_value() {
|
|
awk -v k="$1" -F= '$1 == k { sub(/^[^=]*=/, ""); print; exit }' "$PROV_KEYS"
|
|
}
|
|
|
|
# keys_len <VAR> — the length, and a refusal naming the variable when absent.
|
|
# The value itself is never printed.
|
|
keys_len() {
|
|
local v
|
|
v="$(keys_value "$1")"
|
|
[ -n "$v" ] || die "$PROV_KEYS has no $1= line"
|
|
printf '%s' "${#v}"
|
|
}
|
|
|
|
# xml_value <file> <element> — the element's text, empty when it is absent or
|
|
# self-closing.
|
|
xml_value() {
|
|
python3 - "$1" "$2" <<'PYEOF'
|
|
import re, sys
|
|
src = open(sys.argv[1], encoding="utf-8").read()
|
|
m = re.search(r"<%s(?:\s*/>|>(.*?)</%s>)" % (sys.argv[2], sys.argv[2]), src, re.S)
|
|
print(m.group(1).strip() if (m and m.group(1)) else "")
|
|
PYEOF
|
|
}
|
|
|
|
# xml_secret <file> <element> <var> — set <element> to that variable from the
|
|
# env file. The python process reads the env file itself, so the value never
|
|
# appears in argv.
|
|
xml_secret() {
|
|
python3 - "$1" "$2" "$3" "$PROV_KEYS" <<'PYEOF'
|
|
import os, re, sys, tempfile
|
|
from xml.sax.saxutils import escape
|
|
|
|
xml_path, elem, var, keys_path = sys.argv[1:5]
|
|
val = ""
|
|
for line in open(keys_path, encoding="utf-8"):
|
|
key, sep, rest = line.partition("=")
|
|
if sep and key.strip() == var:
|
|
val = rest.strip()
|
|
break
|
|
if not val:
|
|
raise SystemExit(f"{keys_path} has no {var}= line")
|
|
|
|
src = open(xml_path, encoding="utf-8").read()
|
|
pat = re.compile(r"([ \t]*)<%s(?:\s*/>|>.*?</%s>)" % (elem, elem), re.S)
|
|
text = escape(val)
|
|
if pat.search(src):
|
|
out = pat.sub(lambda m: f"{m.group(1)}<{elem}>{text}</{elem}>", src, count=1)
|
|
else:
|
|
# The plugin owns this file and writes it under ITS OWN root element
|
|
# (TheTVDB's is <PluginConfiguration>), and its serializer omits a
|
|
# null-valued setting entirely — so the element being filled can be
|
|
# absent outright, and a hardcoded </Configuration> anchor then matches
|
|
# nothing. Anchor on the document's real root instead.
|
|
out = src
|
|
root = re.search(r"<([A-Za-z_][\w.:-]*)[^>]*>", src)
|
|
if root and ("</%s>" % root.group(1)) in src:
|
|
name = root.group(1)
|
|
out = src.replace(
|
|
"</%s>" % name, f" <{elem}>{text}</{elem}>\n</{name}>", 1
|
|
)
|
|
if out == src:
|
|
raise SystemExit(f"{xml_path}: no place to write <{elem}>")
|
|
|
|
d, base = os.path.split(xml_path)
|
|
fd, tmp = tempfile.mkstemp(prefix=base + ".", dir=d)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(out)
|
|
os.chmod(tmp, os.stat(xml_path).st_mode & 0o777)
|
|
os.replace(tmp, xml_path)
|
|
PYEOF
|
|
}
|
|
|
|
# plugin_meta <zip> — "guid version" out of the artifact's own meta.json.
|
|
plugin_meta() {
|
|
python3 - "$1" <<'PYEOF'
|
|
import json, sys, zipfile
|
|
meta = json.loads(zipfile.ZipFile(sys.argv[1]).read("meta.json").decode("utf-8"))
|
|
print(meta.get("guid", ""), meta.get("version", ""))
|
|
PYEOF
|
|
}
|
|
|
|
check_root() {
|
|
# Never fabricate a data root: creating plugins/ beside a typo'd path would
|
|
# install a plugin Jellyfin never loads and hide the operator's mistake.
|
|
[ -d "$CONF" ] || die "no Jellyfin configuration directory at $CONF (is JF_ROOT right?)"
|
|
}
|
|
|
|
check_keys_report() {
|
|
check_keys
|
|
# A refusal inside a command substitution used as a printf argument would
|
|
# be swallowed, so the guard runs as its own statement.
|
|
keys_len TMDB_API_KEY >/dev/null
|
|
keys_len TVDB_API_KEY >/dev/null
|
|
printf '= keys read from %s (mode %s)\n' "$PROV_KEYS" "$(stat -c '%a' "$PROV_KEYS")"
|
|
}
|
|
|
|
# The plugin's state, as one line in the shared vocabulary.
|
|
plugin_state() {
|
|
if [ -d "$PLUGINS/$PLUGIN_DIR" ]; then
|
|
printf '= %s already installed\n' "$PLUGIN_DIR"
|
|
return 0
|
|
fi
|
|
local other
|
|
other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
|
|
if [ -n "$other" ]; then
|
|
printf '+ install %s (a different version is present: %s)\n' "$PLUGIN_DIR" "$other"
|
|
return 0
|
|
fi
|
|
printf '+ install %s from %s\n' "$PLUGIN_DIR" "${TVDB_PLUGIN_URL#*://}"
|
|
}
|
|
|
|
tmdb_state() {
|
|
if [ ! -f "$TMDB_XML" ]; then
|
|
printf '! %s is missing — save the TMDb plugin settings once in the UI, then re-run\n' "$TMDB_XML" >&2
|
|
return 1
|
|
fi
|
|
local now want
|
|
now="$(xml_value "$TMDB_XML" TmdbApiKey)"
|
|
want="$(keys_value TMDB_API_KEY)"
|
|
if [ -n "$now" ] && [ "$now" = "$want" ]; then
|
|
printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
|
|
elif [ -n "$now" ]; then
|
|
printf '+ replace the TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
|
|
else
|
|
printf '+ set TmdbApiKey in Jellyfin.Plugin.Tmdb.xml\n'
|
|
fi
|
|
}
|
|
|
|
tvdb_state() {
|
|
local now
|
|
if [ -f "$TVDB_XML" ]; then
|
|
now="$(xml_value "$TVDB_XML" ApiKey)"
|
|
if [ -n "$now" ]; then
|
|
printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
|
|
else
|
|
printf '+ fill the empty ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
|
|
fi
|
|
else
|
|
printf '+ create Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
|
|
fi
|
|
}
|
|
|
|
write_plugin() {
|
|
local tmp guid version other
|
|
other="$(find "$PLUGINS" -maxdepth 1 -type d -name 'thetvdb_*' ! -name "$PLUGIN_DIR" -printf '%f\n' 2>/dev/null | sort | tr '\n' ' ')"
|
|
[ -z "$other" ] ||
|
|
die "another TheTVDB version is already installed ($other); move it to the trash first — two copies of one plugin means two loads"
|
|
|
|
tmp="$(mktemp "${TMPDIR:-/tmp}/thetvdb.XXXXXX.zip")"
|
|
# The pins are checked before anything is unpacked, so a bad artifact never
|
|
# reaches the plugins directory.
|
|
curl -fsSL --max-time 300 -o "$tmp" "$TVDB_PLUGIN_URL" ||
|
|
{ rm -f "$tmp"; die "could not fetch $TVDB_PLUGIN_URL"; }
|
|
local got
|
|
got="$(sha256sum "$tmp" | cut -d' ' -f1)"
|
|
if [ "$got" != "$TVDB_PLUGIN_SHA256" ]; then
|
|
rm -f "$tmp"
|
|
die "artifact sha256 is $got, pinned $TVDB_PLUGIN_SHA256 — refusing to install"
|
|
fi
|
|
read -r guid version <<<"$(plugin_meta "$tmp")"
|
|
if [ "$guid" != "$TVDB_PLUGIN_GUID" ]; then
|
|
rm -f "$tmp"
|
|
die "artifact guid is $guid, expected the official $TVDB_PLUGIN_GUID — refusing to install"
|
|
fi
|
|
if [ "$version" != "$TVDB_PLUGIN_VERSION" ]; then
|
|
rm -f "$tmp"
|
|
die "artifact version is $version, pinned $TVDB_PLUGIN_VERSION — refusing to install"
|
|
fi
|
|
mkdir -p "$PLUGINS/$PLUGIN_DIR"
|
|
python3 -m zipfile -e "$tmp" "$PLUGINS/$PLUGIN_DIR/"
|
|
rm -f "$tmp"
|
|
[ -f "$PLUGINS/$PLUGIN_DIR/$TVDB_DLL" ] ||
|
|
die "$PLUGIN_DIR has no $TVDB_DLL — the artifact does not match this plugin"
|
|
printf '+ installed %s\n' "$PLUGIN_DIR"
|
|
}
|
|
|
|
backup_to_trash() {
|
|
local f="$1"
|
|
mkdir -p "$TRASH_DIR"
|
|
local dest="$TRASH_DIR/$(basename "$f").$(date +%H%M%S)"
|
|
cp -p "$f" "$dest"
|
|
printf '+ previous copy kept at %s\n' "$dest"
|
|
}
|
|
|
|
cmd_write_keys() {
|
|
local now want
|
|
KEYS_CHANGED=0
|
|
# TMDb: the running container owns this file and every setting in it. Only
|
|
# the key element is touched, so country, language and adult settings
|
|
# survive the edit.
|
|
[ -f "$TMDB_XML" ] ||
|
|
die "$TMDB_XML is missing — save the TMDb plugin settings once in the UI, then re-run"
|
|
now="$(xml_value "$TMDB_XML" TmdbApiKey)"
|
|
keys_len TMDB_API_KEY >/dev/null
|
|
want="$(keys_value TMDB_API_KEY)"
|
|
if [ "$now" = "$want" ]; then
|
|
printf '= TmdbApiKey already set (%s chars)\n' "${#now}"
|
|
else
|
|
backup_to_trash "$TMDB_XML"
|
|
xml_secret "$TMDB_XML" TmdbApiKey TMDB_API_KEY
|
|
printf '+ set TmdbApiKey (%s chars)\n' "${#want}"
|
|
KEYS_CHANGED=1
|
|
fi
|
|
|
|
# TVDB: never overwrite a config the operator saved through the UI.
|
|
if [ -f "$TVDB_XML" ]; then
|
|
now="$(xml_value "$TVDB_XML" ApiKey)"
|
|
if [ -n "$now" ]; then
|
|
printf '= Jellyfin.Plugin.Tvdb.xml already has an ApiKey (kept)\n'
|
|
else
|
|
keys_len TVDB_API_KEY >/dev/null
|
|
backup_to_trash "$TVDB_XML"
|
|
xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
|
|
if [ -n "$(keys_value TVDB_PIN)" ] && [ -z "$(xml_value "$TVDB_XML" Pin)" ]; then
|
|
xml_secret "$TVDB_XML" Pin TVDB_PIN
|
|
fi
|
|
printf '+ filled the ApiKey in Jellyfin.Plugin.Tvdb.xml\n'
|
|
KEYS_CHANGED=1
|
|
fi
|
|
else
|
|
keys_len TVDB_API_KEY >/dev/null
|
|
cat >"$TVDB_XML" <<XML
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<Configuration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
<ApiKey />
|
|
</Configuration>
|
|
XML
|
|
xml_secret "$TVDB_XML" ApiKey TVDB_API_KEY
|
|
if [ -n "$(keys_value TVDB_PIN)" ]; then
|
|
xml_secret "$TVDB_XML" Pin TVDB_PIN
|
|
fi
|
|
printf '+ created Jellyfin.Plugin.Tvdb.xml with ApiKey\n'
|
|
KEYS_CHANGED=1
|
|
fi
|
|
}
|
|
|
|
cmd_plan() {
|
|
check_root
|
|
check_keys_report
|
|
printf 'root %s\n' "$JF_ROOT"
|
|
plugin_state
|
|
tmdb_state
|
|
tvdb_state
|
|
}
|
|
|
|
cmd_install() {
|
|
check_root
|
|
check_keys_report
|
|
KEYS_CHANGED=0
|
|
if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
|
|
write_plugin
|
|
KEYS_CHANGED=1
|
|
else
|
|
printf '= %s already installed\n' "$PLUGIN_DIR"
|
|
fi
|
|
cmd_write_keys
|
|
if [ "$KEYS_CHANGED" -eq 1 ]; then
|
|
# Nothing is restarted here on purpose: this host may be playing media
|
|
# right now, and both the plugin and its keys are read at start-up.
|
|
printf 'next: scripts/deploy.sh dankserver --service jellyfin (the plugin and keys load at start-up)\n'
|
|
else
|
|
printf '= no change\n'
|
|
fi
|
|
}
|
|
|
|
|
|
# A library whose options.xml carries EnableInternetProviders=false, or an empty
|
|
# ImageFetchers list beside a populated MetadataFetchers list, is a SILENT FILE
|
|
# INDEX: it indexes every file, answers 204 to every refresh, stores no provider
|
|
# id, plot, rating or artwork, and logs nothing — so the item count reads like a
|
|
# healthy scan. Jellyfin 12 keeps these options in root/default/<Library>/options.xml
|
|
# (it was library.xml) and stores NONE of them in the database, so this audit is
|
|
# the only place the state can be read without a browser session.
|
|
verify_library_options() {
|
|
local root="$JF_ROOT/root/default" f lib problems
|
|
if [ ! -d "$root" ]; then
|
|
printf '= no library options under %s (nothing audited)\n' "$root"
|
|
return 0
|
|
fi
|
|
local rc=0
|
|
for f in "$root"/*/options.xml; do
|
|
[ -f "$f" ] || continue
|
|
lib="$(basename "$(dirname "$f")")"
|
|
problems="$(python3 -s - "$f" <<'PY'
|
|
import sys, xml.dom.minidom as X
|
|
root = X.parse(sys.argv[1]).documentElement
|
|
def child(el, name):
|
|
for n in el.childNodes:
|
|
if n.nodeType == n.ELEMENT_NODE and n.tagName == name:
|
|
return n
|
|
def flag(el, name):
|
|
n = child(el, name)
|
|
return None if n is None else (n.firstChild.data.strip() if n.firstChild else "")
|
|
def names(el, name):
|
|
n = child(el, name)
|
|
return None if n is None else [s.firstChild.data.strip() for s in n.childNodes
|
|
if s.nodeType == s.ELEMENT_NODE and s.firstChild]
|
|
bad = []
|
|
ip = flag(root, "EnableInternetProviders")
|
|
if ip is not None and ip.lower() != "true":
|
|
bad.append("EnableInternetProviders=%s" % (ip or "empty"))
|
|
types = child(root, "TypeOptions")
|
|
if types is not None:
|
|
for t in types.childNodes:
|
|
if t.nodeType != t.ELEMENT_NODE or t.tagName != "TypeOptions":
|
|
continue
|
|
meta = names(t, "MetadataFetchers") or []
|
|
if meta and not (names(t, "ImageFetchers") or []):
|
|
bad.append("%s: ImageFetchers empty while MetadataFetchers names %d"
|
|
% (flag(t, "Type") or "?", len(meta)))
|
|
print("; ".join(bad))
|
|
PY
|
|
)"
|
|
if [ -n "$problems" ]; then
|
|
printf 'SILENT INDEX %s: %s\n' "$lib" "$problems" >&2
|
|
rc=1
|
|
else
|
|
printf '= %s: library options fetch from providers\n' "$lib"
|
|
fi
|
|
done
|
|
return "$rc"
|
|
}
|
|
|
|
cmd_verify() {
|
|
check_root
|
|
local bad=0
|
|
if [ ! -d "$PLUGINS/$PLUGIN_DIR" ]; then
|
|
printf 'MISSING plugin directory %s (thetvdb not installed)\n' "$PLUGIN_DIR" >&2
|
|
bad=1
|
|
else
|
|
local dlls abi
|
|
dlls="$(find "$PLUGINS/$PLUGIN_DIR" -maxdepth 1 -name '*.dll' | wc -l)"
|
|
[ "$dlls" -ge 1 ] || {
|
|
printf 'MISSING %s inside %s\n' "$TVDB_DLL" "$PLUGIN_DIR" >&2
|
|
bad=1
|
|
}
|
|
abi="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("targetAbi","?"))' \
|
|
"$PLUGINS/$PLUGIN_DIR/meta.json" 2>/dev/null || echo '?')"
|
|
printf 'plugin %s: %s dll(s), targetAbi %s\n' "$PLUGIN_DIR" "$dlls" "$abi"
|
|
fi
|
|
local pair elem n
|
|
for pair in "$TMDB_XML|TmdbApiKey" "$TVDB_XML|ApiKey"; do
|
|
elem="${pair##*|}"
|
|
if [ ! -f "${pair%%|*}" ]; then
|
|
printf 'MISSING %s\n' "${pair%%|*}" >&2
|
|
bad=1
|
|
continue
|
|
fi
|
|
if ! python3 -c 'import sys, xml.dom.minidom; xml.dom.minidom.parse(sys.argv[1])' "${pair%%|*}"; then
|
|
printf 'UNPARSEABLE %s\n' "${pair%%|*}" >&2
|
|
bad=1
|
|
continue
|
|
fi
|
|
n="$(xml_value "${pair%%|*}" "$elem")"
|
|
if [ -z "$n" ]; then
|
|
printf 'MISSING key: %s has no %s value\n' "$(basename "${pair%%|*}")" "$elem" >&2
|
|
bad=1
|
|
else
|
|
printf '%s %s chars\n' "$elem" "${#n}"
|
|
fi
|
|
done
|
|
verify_library_options || bad=1
|
|
[ "$bad" -eq 0 ] || return 1
|
|
printf 'providers configured\n'
|
|
}
|
|
|
|
cmd="${1:-}"
|
|
case "$cmd" in
|
|
plan) cmd_plan ;;
|
|
install) cmd_install ;;
|
|
verify) cmd_verify ;;
|
|
*) usage ;;
|
|
esac
|