#!/usr/bin/env python3 """lp-task-names — fail if any dispatched command has no task title. lp-task-names # check, print a table, exit 1 on any failure lp-task-names --quiet # only print failures Task titles come from ONE place: formatCommandForUser() in components/tasks/js/tasks-format.js, a declarative table of command patterns whose final fallback returns the raw command string. That fallback is why bad titles keep reappearing — a command shape with no row does not error, it just renders as "libreportal instance remove bookstack_work" next to properly named neighbours like "Bookstack - Create Backup". This reads BOTH files as source, so a command added to task-commands.js without a matching row in tasks-format.js fails here rather than in front of a user. Two things are checked, both read from source rather than guessed from the rendered text: 1. No command falls through. A title that equals its command, or still starts with "libreportal ", means the raw fallback was reached. 2. Every `libreportal app ` verb has an actionMap entry. Without one the generic branch composes " Application", which is how "Up Application" and "Down Application" shipped — technically named, but not English. This is deliberately NOT inferred from the title: "Reload Application" is both a correct hand-written label and what the generic branch would emit, so only the source says which it is. Needs a node. Uses the host's if present, otherwise borrows the one inside the running libreportal container. DEV TOOL — scripts/dev is export-ignored, so this never ships. """ import json import os import re import shutil import subprocess import sys ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..")) FRONTEND = os.path.join(ROOT, "containers", "libreportal", "frontend") COMMANDS = os.path.join(FRONTEND, "core", "tasks", "js", "task-commands.js") ACTIONS = os.path.join(FRONTEND, "core", "tasks", "js", "task-actions.js") FORMAT = os.path.join(FRONTEND, "components", "tasks", "js", "tasks-format.js") # Stand-ins for the {placeholders} in a command template. Values only have to be # shaped like the real thing — the patterns match on structure, not content. SAMPLES = { "appName": "bookstack", "type": "bookstack", "name": "work", "service": "bookstack-db", "config": "", "backupId": "backup_20260101", "toolId": "list_users", "args": "", # task-actions.js interpolations (see actions_templates below) "app": "bookstack", "slug": "bookstack", "serviceName": "bookstack-db", "v": "1.2.3", "cmd": "", "id": "hotfix-1", "flags.join(' ')": "", "backupFile": "backup_20260101.tar.gz", "changes": "CFG_X=1", "flag": "", "list": "bookstack,navidrome", "list.join(',')": "bookstack,navidrome", "deleteRemote1": "false", "deleteRemote2": "false", "action": "install", "toolName": "list_users", "version": "1.2.3", "config.replace(/'/g, \"'\\\\''\")": "", } def die(msg, code=1): print(f"lp-task-names: {msg}", file=sys.stderr) sys.exit(code) def node_runner(): """Return a callable that runs a JS file and gives back its stdout.""" host = shutil.which("node") if host: return lambda path: subprocess.run( [host, path], capture_output=True, text=True, timeout=120) # No host node: hand the script to the container's on stdin, so nothing has # to be copied into a bind mount the caller may not be able to write. def via_container(path): inner = ("export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock; " "docker exec -i libreportal-service sh -c " "'cat > /tmp/lp-task-names.js && node /tmp/lp-task-names.js'") with open(path, "rb") as fh: return subprocess.run(["sudo", "-n", "-u", "dockerinstall", "bash", "-c", inner], stdin=fh, capture_output=True, text=True, timeout=180) return via_container def action_map_verbs(): """Verb keys of the actionMap in tasks-format.js's generic-command branch.""" src = open(FORMAT, encoding="utf-8").read() m = re.search(r"const actionMap\s*=\s*\{(.*?)\};", src, re.S) if not m: die("could not find actionMap in tasks-format.js — has its shape changed?") return set(re.findall(r"'([a-z_]+)'\s*:", m.group(1))) def command_templates(): """Every uncommented `key: 'libreportal …'` entry in task-commands.js.""" src = open(COMMANDS, encoding="utf-8").read() out = [] for line in src.splitlines(): stripped = line.strip() if stripped.startswith("//"): continue m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*'(libreportal [^']*)'", stripped) if m: out.append((m.group(1), m.group(2))) return out def actions_templates(): """Every backtick `libreportal …` template built in task-actions.js. This is the file the original guard did not read, and precisely where the gap it exists to catch actually happened: `libreportal updater upgrade ` is assembled here, never appears in task-commands.js, and shipped with no title row — every upgrade task rendered as its raw command. A guard that only reads one of the two dispatch sites certifies half the surface and reports it as all of it. JS ${expr} interpolations become {expr} placeholders and go through the same SAMPLES fill as the {name} ones in task-commands.js. A template using an expression SAMPLES does not know is a die(), not a skip — an unknown placeholder is a command the guard cannot vouch for. """ src = open(ACTIONS, encoding="utf-8").read() # Strip comment lines first: prose mentions commands in backticks too # ("// index via `libreportal app add` as a task"), and reading those as # dispatch sites reports fictional commands as unnamed. src = "\n".join(l for l in src.splitlines() if not l.strip().startswith("//")) out = [] for m in re.finditer(r"`(libreportal [^`]*)`", src): tmpl = re.sub(r"\$\{([^}]+)\}", r"{\1}", m.group(1)) # conditional-suffix templates (`… ${v ? … : …}`) collapse to their bare # form: the guard cares that the COMMAND SHAPE has a title row, and the # regex rows match on prefix. tmpl = re.sub(r"\{[^}]*\?[^}]*\}", "", tmpl) out.append(("task-actions.js", tmpl.strip())) return out def fill(template): def sub(m): key = m.group(1) if key not in SAMPLES: die(f"template uses unknown placeholder {{{key}}} — add it to SAMPLES") return SAMPLES[key] return re.sub(r"\{(\w+)\}", sub, template).strip() def main(): quiet = "--quiet" in sys.argv for path in (COMMANDS, FORMAT): if not os.path.isfile(path): die(f"missing {path}") cases = [(key, fill(tpl)) for key, tpl in command_templates()] if not cases: die("no command templates found in task-commands.js — has its shape changed?") # Second dispatch site — the one the guard originally missed (see # actions_templates). Dedupe by filled command so shapes both files build # are not reported twice. seen = {c for _, c in cases} for key, tpl in actions_templates(): filled = fill(tpl) if filled not in seen: seen.add(filled) cases.append((key, filled)) harness = os.path.join("/tmp", f"lp-task-names-{os.getpid()}.js") with open(harness, "w", encoding="utf-8") as fh: # window.getAppDisplayName is the WebUI's slug -> title helper; the real # one reads window.apps, which does not exist outside a browser. fh.write("global.window = { getAppDisplayName: (s) => " "s.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' \\u00b7 ') };\n") fh.write("class TasksManager {}\n") fh.write(open(FORMAT, encoding="utf-8").read()) fh.write("\nconst t = new TasksManager();\n") fh.write("const cases = " + json.dumps([c for _, c in cases]) + ";\n") fh.write("console.log(JSON.stringify(cases.map(c => t.formatCommandForUser({command: c}))));\n") try: res = node_runner()(harness) finally: try: os.unlink(harness) except OSError: pass if res.returncode != 0: die(f"could not run the formatter:\n{res.stderr.strip() or res.stdout.strip()}") try: titles = json.loads(res.stdout.strip().splitlines()[-1]) except (ValueError, IndexError): die(f"formatter produced no usable output:\n{res.stdout}\n{res.stderr}") verbs = action_map_verbs() failures = [] width = max(len(c) for _, c in cases) for (key, command), title in zip(cases, titles): why = None if title == command or title.startswith("libreportal "): why = "no pattern — fell through to the raw command" else: m = re.match(r"^libreportal app ([a-z_]+)\b", command) if m and m.group(1) not in verbs: why = (f"verb '{m.group(1)}' has no actionMap entry — the generic branch " f"would compose ' Application'") if why: failures.append((key, command, title, why)) if not quiet: print(f" {'FAIL' if why else 'ok '} {command.ljust(width)} -> {title}") print() if failures: print(f" {len(failures)} of {len(cases)} dispatched command(s) have no proper task name:\n") for key, command, title, why in failures: print(f" {key}: {command}") print(f" renders as : {title}") print(f" problem : {why}") print("\n Fix: add a PATTERNS row (or an actionMap entry) in") print(f" {os.path.relpath(FORMAT, ROOT)}") return 1 print(f" all {len(cases)} dispatched commands have a proper task name.") return 0 if __name__ == "__main__": sys.exit(main())