diff --git a/.github/scripts/tests/test_helper_addon_target.py b/.github/scripts/tests/test_helper_addon_target.py
new file mode 100644
index 00000000..64ba2a9c
--- /dev/null
+++ b/.github/scripts/tests/test_helper_addon_target.py
@@ -0,0 +1,108 @@
+"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
+the Proxmox host, inside a container, or wherever the user chooses when it
+can be both. Until this, every addon ran on the host."""
+
+from pathlib import Path
+import subprocess
+import textwrap
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
+PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
+
+# Only the functions under test are loaded, not the menu or its catalog.
+PRELUDE = textwrap.dedent(f"""
+ set -uo pipefail
+ eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
+ translate() {{ printf '%s' "$1"; }}
+ clear() {{ :; }}
+ dialog() {{ :; }}
+ pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
+ MENU_HEIGHT=22; MENU_ROWS=14
+""")
+
+
+def bash(script):
+ return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
+
+
+class TargetChoiceTests(unittest.TestCase):
+ def choice(self, kind, places):
+ return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
+
+ def test_an_addon_for_the_host_runs_on_the_host(self):
+ self.assertEqual(self.choice("addon", "pve"), "host")
+
+ def test_an_addon_for_containers_runs_in_one(self):
+ self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
+ self.assertEqual(self.choice("addon", "lxc"), "lxc")
+
+ def test_an_addon_for_both_asks_where(self):
+ self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
+ self.assertEqual(self.choice("addon", ""), "ask")
+
+ def test_what_is_not_an_addon_runs_on_the_host(self):
+ # A script that creates a container or a VM belongs on the host,
+ # whatever the catalog lists for the application it installs.
+ self.assertEqual(self.choice("lxc", "lxc"), "host")
+ self.assertEqual(self.choice("vm", "vm"), "host")
+ self.assertEqual(self.choice("pve", "pve"), "host")
+
+
+class ContainerListTests(unittest.TestCase):
+ def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
+ result = bash(r"""
+ pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
+ helper_lxc_rows
+ """)
+ self.assertEqual(result.stdout,
+ "109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
+
+
+class RunTests(unittest.TestCase):
+ def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
+ result = bash(r"""
+ curl() { printf 'printf on-host'; }
+ pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
+ HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
+ HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
+ """)
+ self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
+
+ def test_a_failed_or_empty_download_runs_nothing(self):
+ result = bash(r"""
+ bash() { echo RAN; }
+ pct() { echo RAN; }
+ HELPER_LXC_TARGET=109
+ curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
+ curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
+ echo done
+ """)
+ self.assertEqual(result.stdout, "done\n")
+
+ def test_cancelling_the_choice_leaves_no_target(self):
+ result = bash(r"""
+ dialog() { return 1; }
+ select_helper_target Test ask && echo UNEXPECTED
+ printf '[%s]\n' "$HELPER_LXC_TARGET"
+ """)
+ self.assertEqual(result.stdout, "[]\n")
+
+ def test_the_host_needs_no_question(self):
+ result = bash(r"""
+ dialog() { echo ASKED; return 1; }
+ select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
+ """)
+ self.assertEqual(result.stdout, "ok[]\n")
+
+
+class PlainTextTests(unittest.TestCase):
+ def test_html_becomes_text_and_commands_are_kept(self):
+ result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
+ input="
Run ls -la && pwd
")
+ self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/menus/helper_plain_text.py b/scripts/menus/helper_plain_text.py
new file mode 100644
index 00000000..b81857d5
--- /dev/null
+++ b/scripts/menus/helper_plain_text.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""Convert catalogue HTML to terminal text without altering shell examples."""
+import re
+import sys
+from html.parser import HTMLParser
+
+
+class PlainText(HTMLParser):
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.parts = []
+ self.hidden = 0
+
+ def handle_starttag(self, tag, attrs):
+ if tag in ('script', 'style'):
+ self.hidden += 1
+ elif not self.hidden:
+ if tag in ('p', 'div', 'br', 'ul', 'ol', 'pre', 'blockquote',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
+ self.parts.append('\n')
+ elif tag == 'li':
+ self.parts.append('\n• ')
+
+ def handle_endtag(self, tag):
+ if tag in ('script', 'style'):
+ self.hidden = max(0, self.hidden - 1)
+ elif not self.hidden and tag in ('p', 'div', 'li', 'ul', 'ol', 'pre',
+ 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
+ self.parts.append('\n')
+
+ def handle_data(self, data):
+ if not self.hidden:
+ self.parts.append(data)
+
+
+def plain_text(value):
+ parser = PlainText()
+ parser.feed(value)
+ parser.close()
+ result = ''.join(parser.parts).replace('\xa0', ' ')
+ result = '\n'.join(line.strip() for line in result.splitlines())
+ return re.sub(r'\n{3,}', '\n\n', result).strip()
+
+
+if __name__ == '__main__':
+ sys.stdout.write(plain_text(sys.stdin.read()))
diff --git a/scripts/menus/menu_Helper_Scripts.sh b/scripts/menus/menu_Helper_Scripts.sh
index add6d3ea..ab0fcaf0 100644
--- a/scripts/menus/menu_Helper_Scripts.sh
+++ b/scripts/menus/menu_Helper_Scripts.sh
@@ -60,15 +60,22 @@ done
# Without these the menu would hang silently for minutes when the network
# is down or the upstream is slow, with no signal to the user. Audit
# Tier 6 — `menu_Helper_Scripts.sh` curl sin --max-time.
-CACHE_JSON=$(curl -s --connect-timeout 5 --max-time 15 "$HELPERS_JSON_URL")
+CACHE_JSON=$(curl -fsSL --connect-timeout 5 --max-time 15 "$HELPERS_JSON_URL")
# Validate that the JSON loaded correctly
if ! echo "$CACHE_JSON" | jq -e 'if type == "array" and length > 0 then true else false end' >/dev/null 2>&1; then
dialog --title "Helper Scripts" \
--msgbox "Error: Could not load helpers cache.\nCheck your internet connection and try again.\n\nURL: $HELPERS_JSON_URL" 10 70
+ # Opened for one script, as the menu search does: back to where it came from.
+ [[ -n "${1:-}" ]] && exit 1
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
fi
+# The menus are as tall as the main menu, cut to the terminal.
+MENU_HEIGHT=$(( $(tput lines 2>/dev/null || echo 24) - 2 ))
+(( MENU_HEIGHT > 24 )) && MENU_HEIGHT=24
+MENU_ROWS=$(( MENU_HEIGHT - 8 ))
+
# ---------------------------------------------------------------------------
# Build category map directly from the cache (id → name).
# Uses transpose to pair categories[] and category_names[] arrays — no
@@ -107,15 +114,99 @@ get_type_label() {
# Download and execute a script URL, with optional mirror fallback
# ---------------------------------------------------------------------------
download_script() {
- local url="$1"
+ local url="$1" script_content
- # Same timeouts as the cache fetch above so a stalled mirror can't hang
- # the menu indefinitely. The HEAD probe and the GET both get bounded.
- if curl --silent --connect-timeout 5 --max-time 10 --head --fail "$url" >/dev/null; then
- bash <(curl -s --connect-timeout 5 --max-time 60 "$url")
- else
+ # The script is downloaded whole before anything runs: a download that
+ # fails or comes back empty runs nothing, on the host or in a container.
+ if ! script_content=$(curl -fsSL --connect-timeout 5 --max-time 60 "$url") || [[ -z "${script_content//[[:space:]]/}" ]]; then
dialog --title "Helper Scripts" --msgbox "$(translate "Error: Failed to download the script.")" 8 70
+ return 1
fi
+ clear
+ if [[ -n "${HELPER_LXC_TARGET:-}" ]]; then
+ pct exec "$HELPER_LXC_TARGET" --keep-env 0 -- \
+ env TERM="${TERM:-xterm}" bash -c "$script_content"
+ else
+ bash -c "$script_content"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Where an addon runs. The catalog says it for each one: on the Proxmox host,
+# inside a container, or either. Everything that is not an addon runs on the
+# host, which is where a script that creates a container or a VM belongs.
+# $1 type $2 the "execute_in" of the catalog, separated by spaces
+# Prints host, lxc or ask.
+# ---------------------------------------------------------------------------
+helper_target_choice() {
+ local type="$1" places=" $2 " host=0 lxc=0
+ [[ "$type" == addon ]] || { echo host; return; }
+ [[ "$places" == *" pve "* ]] && host=1
+ [[ "$places" == *" lxc "* ]] && lxc=1
+ if (( host == 1 && lxc == 0 )); then
+ echo host
+ elif (( lxc == 1 && host == 0 )); then
+ echo lxc
+ else
+ echo ask
+ fi
+}
+
+# The containers an addon can be run in: every LXC but the OCI ones, which
+# are an application image and not a system to add things to.
+helper_lxc_rows() {
+ local ctid status lock name
+ while read -r ctid status lock name; do
+ [[ "$ctid" =~ ^[0-9]+$ ]] || continue
+ pmx_lxc_is_oci "$ctid" && continue
+ # pct list leaves the Lock column blank for a container that has none.
+ [[ -n "$name" ]] || { name="$lock"; lock=""; }
+ printf '%s\t%s · %s%s\n' "$ctid" "$name" "$status" "${lock:+ · $lock}"
+ done < <(pct list)
+}
+
+# Asks where to run an addon and leaves the container in HELPER_LXC_TARGET,
+# empty for the host. Returns 1 when nothing has to run.
+# $1 name of the addon $2 host, lxc or ask
+select_helper_target() {
+ HELPER_LXC_TARGET=""
+ local title="$1" target="$2" ctid label state
+ [[ "$target" == host ]] && return 0
+ if [[ "$target" == ask ]]; then
+ target=$(dialog --clear --backtitle "ProxMenux" --title "$title" \
+ --menu "$(translate "Where do you want to run this addon?")" 12 75 2 \
+ lxc "$(translate "In an LXC")" \
+ host "$(translate "On the host")" 3>&1 1>&2 2>&3) || return 1
+ [[ "$target" == host ]] && return 0
+ fi
+ [[ "$target" == lxc ]] || return 1
+ local -a containers=()
+ while IFS=$'\t' read -r ctid label; do
+ containers+=("$ctid" "$label")
+ done < <(helper_lxc_rows)
+ if [[ ${#containers[@]} -eq 0 ]]; then
+ dialog --title "$title" --msgbox "$(translate "There are no LXC containers available. OCI containers are not included.")" 9 75
+ return 1
+ fi
+ ctid=$(dialog --clear --backtitle "ProxMenux" --title "$title · LXC" \
+ --menu "$(translate "Select the LXC. OCI containers are not included.")" "$MENU_HEIGHT" 75 "$MENU_ROWS" \
+ "${containers[@]}" 3>&1 1>&2 2>&3) || return 1
+ [[ "$ctid" =~ ^[0-9]+$ && -f "/etc/pve/lxc/$ctid.conf" ]] || return 1
+ pmx_lxc_is_oci "$ctid" && return 1
+ state=$(pct status "$ctid") || return 1
+ if [[ "$state" != "status: running" ]]; then
+ dialog --backtitle "ProxMenux" --title "$title · LXC $ctid" \
+ --yesno "\n$(translate "The LXC is stopped. Do you want to start it to run the addon?")" 10 75 || return 1
+ if ! pct start "$ctid"; then
+ dialog --title "$title · LXC $ctid" --msgbox "$(translate "The LXC could not be started.")" 8 75
+ return 1
+ fi
+ fi
+ if ! pct exec "$ctid" --keep-env 0 -- sh -c 'command -v bash >/dev/null'; then
+ dialog --title "$title · LXC $ctid" --msgbox "$(translate "The LXC has no Bash to run this script with. The addon was not run.")" 9 75
+ return 1
+ fi
+ HELPER_LXC_TARGET="$ctid"
}
RETURN_TO_MAIN=false
@@ -172,13 +263,15 @@ run_script_by_slug() {
local first="${script_infos[0]}"
local name desc notes warnings port website
name=$(decode "$first" ".name")
- desc=$(decode "$first" ".desc")
- notes=$(decode "$first" '.notes // [] | join("\n")')
+ # The catalog writes some of its texts in HTML.
+ local plain="$LOCAL_SCRIPTS/menus/helper_plain_text.py"
+ desc=$(decode "$first" ".desc" | python3 "$plain")
+ notes=$(decode "$first" '.notes // [] | join("\n")' | python3 "$plain")
# Sprint 11.7: PocketBase upstream tags certain notes with type=="warning".
# The cache generator splits them into a separate `warnings` array so the
# menu can render them in red with a dedicated WARNINGS header instead of
# burying them inside the regular notes block.
- warnings=$(decode "$first" '.warnings // [] | join("\n")')
+ warnings=$(decode "$first" '.warnings // [] | join("\n")' | python3 "$plain")
port=$(decode "$first" ".port // 0")
website=$(decode "$first" ".website // empty")
@@ -271,6 +364,10 @@ run_script_by_slug() {
gh_url=$(decode "$selected" ".script_url")
mirror_url=$(decode "$selected" ".script_url_mirror // empty")
+ # An addon is run where the catalog says it belongs.
+ select_helper_target "$name" "$(helper_target_choice \
+ "$(decode "$selected" '.type // empty')" "$(decode "$selected" '.execute_in // [] | join(" ")')")" || return
+
if [[ "$sel_src" == "GH" ]]; then
download_script "$gh_url"
elif [[ "$sel_src" == "MR" ]]; then
@@ -388,7 +485,7 @@ search_and_filter_scripts() {
selected=$(dialog --colors --backtitle "ProxMenux" \
--title "$title" \
--menu "$(translate "Select a script or action:")" \
- 22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3)
+ "$MENU_HEIGHT" 75 "$MENU_ROWS" "${menu_items[@]}" 3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return
@@ -416,6 +513,12 @@ search_and_filter_scripts() {
done
}
+# A script given as argument, as the menu search does, is opened on its own.
+if [[ -n "${1:-}" ]]; then
+ run_script_by_slug "$1"
+ exit 0
+fi
+
# ---------------------------------------------------------------------------
# Main loop — category list built from embedded category data.
# We map scriptcatXXXXX IDs to short numeric indices so dialog doesn't show
@@ -442,7 +545,7 @@ while true; do
SELECTED_IDX=$(dialog --backtitle "ProxMenux" \
--title "Proxmox VE Helper-Scripts" \
--menu "$(translate "Select a category or search for scripts:")" \
- 22 75 15 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
+ "$MENU_HEIGHT" 75 "$MENU_ROWS" "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
dialog --clear --title "ProxMenux" \
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
@@ -484,7 +587,7 @@ while true; do
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \
--menu "$(translate "Choose a script to execute:"):" \
- 22 75 15 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
+ "$MENU_HEIGHT" 75 "$MENU_ROWS" "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}"
run_script_by_slug "$SCRIPT_SELECTED"