LibrePortal/scripts/dev/lp-task-names
librelad 785df3dcd8 fix(tasks): name upgrade tasks, humanise dead instance slugs, keep icons
Three visible faults on the Tasks page, one shared root.

Upgrade tasks rendered as their raw command — "libreportal updater
upgrade rocketchat 8.7.1" beside properly named neighbours. The title
table had rows for updater check/apply/apply-all/rollback and none for
upgrade, because the upgrade command is assembled in task-actions.js
rather than task-commands.js — and lp-task-names, the guard built to
catch exactly this, only read task-commands.js. It certified 16 commands
and reported that as the whole surface; the surface was 29. The guard
now reads both dispatch sites (JS ${expr} interpolations become sample
placeholders; commented-out prose mentioning commands in backticks is
skipped, or it reports fictional commands), and all 29 pass.

A removed instance's tasks outlive it, and its slug rendered as a tech
identifier: "Bookstack_uitest - Remove Instance". getAppDisplayName
cannot help — it capitalises as its own fallback, so unknown is
indistinguishable from known-and-plain. The formatter now does the same
membership test the helper uses internally: slug absent from
window.apps, prefix before the underscore present -> render the way
live instances are shown, "Bookstack · uitest".

Same story for the icon: bookstack_uitest.svg is deleted with the
instance, and onerror="display:none" left a bare gap in the row. Now a
fallback chain — the TYPE's icon (which survives), then the LibrePortal
logo. Verified live: the dead instance's rows show bookstack.svg with
the fallback marker set, everything else keeps its own icon.

Verified in a real browser session — full render, zero console errors.
The 'add' verb also joins the app-action map so "Add Application" is
deliberate wording rather than the blind "<Verb> Application" compose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:57:30 +01:00

247 lines
9.9 KiB
Python
Executable File

#!/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>` verb has an actionMap entry. Without one the
generic branch composes "<Verb> 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
<app> <version>` 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 '<Verb> 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())