mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 15:17:54 +00:00
feat(webconfig): let the portal discover board-specific CLI commands
The FEM commands moved out of CommonCLI into Board::handleCommand(), and the T-Beam 1W fan control was added there too, but the portal never caught up: `radio.fem.txgain` and the fan commands were missing from the terminal table entirely, while `radio.fem.rxgain` was offered on every board even though a board with no hook now answers "??:" rather than "unsupported". Whether a node answers these is a property of the board, not of the build, so the page cannot know from the firmware version. Ask the board instead: probeBoardCommands() runs each candidate getter once on the loop task at startup and keeps the ones that answer, which needs no per-variant list because Board::handleCommand() already reports whether it handled a command. /api/status names the survivors and the page hides everything else, so adding a command to a variant means one entry in WC_BOARD_CMDS rather than an edit per board. The two FEM keys also become Radio-panel toggles, gated the same way; their `set` reaches the board hook through the existing config batch, so only the allowlist and /api/config needed to grow. webconfig_cli_audit.py now scans variants/*/*Board.cpp for the boards that actually build the portal, checks set-only keys in the reverse direction, and verifies every gate is a command some board answers; the mock gained --board-cmds so both shapes of board are testable. `stop ota` joins NOT_OFFERED: `start ota` cannot run from the portal, so it has nothing to stop.
This commit is contained in:
+107
-22
@@ -15,6 +15,7 @@ that come back an error, so the two stay honest about each other.
|
||||
Exits non-zero if anything fails that is not in EXPECTED_FAILURES. Stdlib only.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -30,12 +31,7 @@ INDEX_HTML = os.path.join(HERE, "..", "webui", "index.html")
|
||||
|
||||
# Errors that are the correct answer, not a gap.
|
||||
EXPECTED_FAILURES = {
|
||||
# Runtime-gated on the real device by Board::canControlLoRaFemLna(); the
|
||||
# command exists in every build and the board answers for itself. The mock
|
||||
# board is a Heltec V3, which has no front-end module.
|
||||
"get radio.fem.rxgain": "unsupported",
|
||||
"set radio.fem.rxgain on": "unsupported",
|
||||
# Guarded by the firmware the same way when no alert PSK is configured.
|
||||
# Guarded by the firmware when no alert PSK is configured.
|
||||
"alert test": "not configured",
|
||||
}
|
||||
|
||||
@@ -44,8 +40,11 @@ SKIP = {"reboot", "clkreboot", "poweroff", "shutdown", "erase", "start ota",
|
||||
"stop webconfig", "ota update", "start webconfig", "start webconfig ap"}
|
||||
|
||||
|
||||
def table():
|
||||
"""The commands autocomplete offers, read straight out of the page."""
|
||||
def table(board_cmds=()):
|
||||
"""The commands autocomplete offers, read straight out of the page.
|
||||
|
||||
`board_cmds` is what /api/status said this board answers; the CLI_BOARD_KEYS
|
||||
entries gated on anything else are not offered and so not driven."""
|
||||
html = open(INDEX_HTML, encoding="utf-8").read()
|
||||
|
||||
def section(start, end):
|
||||
@@ -53,17 +52,27 @@ def table():
|
||||
|
||||
verbs = re.findall(r'\["([^"]+)","', section("var CLI_VERBS=", "var CLI_KEYS="))
|
||||
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
|
||||
section("var CLI_KEYS=", "var CLI_SLOT="))
|
||||
section("var CLI_KEYS=", "var CLI_BOARD_KEYS="))
|
||||
fields = re.findall(r'\["(\w+)","', section("var CLI_SLOT=", "var CLI_TYPES="))
|
||||
|
||||
gets = ["get " + k for k, mode in keys if mode != "2"]
|
||||
gets += ["get mqtt%d.%s" % (n, f) for n in (1, 3) for f in fields]
|
||||
# Verbs taking an argument need a value the node will accept; those are
|
||||
# covered by the round-trip probes below rather than guessed at here.
|
||||
gets += ["get " + k for gate, k, mode in board_table()
|
||||
if mode != "2" and gate in board_cmds]
|
||||
plain = [v for v in verbs if not v.endswith(" ") and v not in SKIP]
|
||||
return gets + plain
|
||||
|
||||
|
||||
def board_table():
|
||||
"""[(gate, key, mode)] from CLI_BOARD_KEYS — the keys the page offers only
|
||||
when /api/status says this board answers for their gate."""
|
||||
html = open(INDEX_HTML, encoding="utf-8").read()
|
||||
section = html[html.index("var CLI_BOARD_KEYS="):html.index("// Per-slot keys")]
|
||||
return re.findall(r'\["([^"]+)","([^"]+)","(?:[^"\\]|\\.)*",(\d)', section)
|
||||
|
||||
|
||||
# Where top-level commands are implemented. MyMesh handles a few before
|
||||
# delegating to CommonCLI, which is exactly how discover.* stayed missing from
|
||||
# the table for so long: grepping CommonCLI alone does not see them.
|
||||
@@ -73,17 +82,56 @@ COMMAND_SOURCES = [
|
||||
"examples/simple_repeater/MyMesh.cpp",
|
||||
]
|
||||
|
||||
# Firmware commands the table deliberately does not offer. Everything below
|
||||
# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the
|
||||
# portal never pretends to run something it cannot.
|
||||
# Board::handleCommand() is dispatched BEFORE CommonCLI's own table, and the FEM
|
||||
# commands now live there rather than in CommonCLI. A board file contributes
|
||||
# whole commands ("get radio.fem.rxgain"), not the bare verbs the sources above
|
||||
# yield, so the two are collected separately and merged.
|
||||
BOARD_SOURCES = "variants/*/*Board.cpp"
|
||||
|
||||
# Firmware commands the table deliberately does not offer.
|
||||
NOT_OFFERED = {
|
||||
"tls.bundletest", # TLS debugging, not an operator command
|
||||
"start ota", # binds port 80, which the portal is already using
|
||||
"stop ota", # nothing to stop: `start ota` cannot run from here
|
||||
"clock sync", # takes its time from the caller; a web request has none
|
||||
"log", # streams to Serial and stalls the radio ("log start" is offered)
|
||||
"get acl", # streams to Serial, returns nothing
|
||||
}
|
||||
|
||||
# Of those, the ones /api/cli does NOT reject at POST (wcCliUnavailable). They
|
||||
# are left out of the table rather than blocked, because running them is
|
||||
# harmless — `stop ota` just reports that no OTA server is running, which is
|
||||
# always true here. Everything else in NOT_OFFERED must come back a 400 with a
|
||||
# reason, so the portal never pretends to run something it cannot.
|
||||
NOT_REFUSED = {"tls.bundletest", "stop ota"}
|
||||
|
||||
|
||||
def webconfig_variants():
|
||||
"""Variant directories whose build serves the portal (ESP32 + MQTT bridge).
|
||||
|
||||
Only these boards can put a command in front of this page; a board command
|
||||
on an nRF52 variant is real but unreachable from here, so it is not a gap.
|
||||
"""
|
||||
out = set()
|
||||
for ini in glob.glob(os.path.join(HERE, "..", "variants", "*", "platformio.ini")):
|
||||
if "WITH_MQTT_BRIDGE" in open(ini, encoding="utf-8").read():
|
||||
out.add(os.path.basename(os.path.dirname(ini)))
|
||||
return out
|
||||
|
||||
|
||||
def board_commands():
|
||||
"""Whole commands Board::handleCommand() answers, across portal variants."""
|
||||
variants = webconfig_variants()
|
||||
found = set()
|
||||
for path in glob.glob(os.path.join(HERE, "..", *BOARD_SOURCES.split("/"))):
|
||||
if os.path.basename(os.path.dirname(path)) not in variants:
|
||||
continue
|
||||
src = open(path, encoding="utf-8").read()
|
||||
body = src[src.find("::handleCommand"):]
|
||||
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', body):
|
||||
found.add(lit.strip())
|
||||
return found
|
||||
|
||||
|
||||
def firmware_commands():
|
||||
"""Top-level command literals the firmware dispatches on."""
|
||||
@@ -96,7 +144,7 @@ def firmware_commands():
|
||||
continue
|
||||
for lit in re.findall(r'(?:mem|str)n?cmp\(\s*command\s*,\s*"([^"]+)"', src):
|
||||
found.add(lit.strip())
|
||||
return found - NOT_OFFERED
|
||||
return (found | board_commands()) - NOT_OFFERED
|
||||
ROUND_TRIPS = [
|
||||
("set radio.watchdog 30", "get radio.watchdog", "30"),
|
||||
("set dutycycle 25", "get dutycycle", "25.0"),
|
||||
@@ -105,6 +153,8 @@ ROUND_TRIPS = [
|
||||
("set mqtt.neighbors on", "get mqtt.neighbors", "on"),
|
||||
("set path.hash.mode 2", "get path.hash.mode", "2"),
|
||||
("set mqtt.iata den", "get mqtt.iata", "DEN"),
|
||||
# Board commands, exercised only when this board answers for them (see
|
||||
# BOARD_ROUND_TRIPS below); the settings form drives the two FEM keys.
|
||||
# Secret reads are masked back down for an HTTP caller, in CommonCLI's own
|
||||
# words for a non-serial one (wcIsSecretReadCommand).
|
||||
("set guest.password hunter2", "get guest.password", "******** (serial only)"),
|
||||
@@ -112,6 +162,14 @@ ROUND_TRIPS = [
|
||||
]
|
||||
|
||||
|
||||
# Gated on /api/status's board_cmds, keyed by the gate the page probes for.
|
||||
BOARD_ROUND_TRIPS = {
|
||||
"radio.fem.rxgain": [("set radio.fem.rxgain off", "get radio.fem.rxgain", "off")],
|
||||
"radio.fem.txgain": [("set radio.fem.txgain on", "get radio.fem.txgain", "on")],
|
||||
"fan": [("set fan on", "get fan", "on 41.0C fan=on cd=0s")],
|
||||
}
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base):
|
||||
self.base = base
|
||||
@@ -119,7 +177,10 @@ class Client:
|
||||
self.cookie = r.headers["Set-Cookie"].split(";")[0]
|
||||
# The node caps a sequence at MAX_BATCH and reports it; chunk to match
|
||||
# rather than hardcoding a number that drifts when the slot is resized.
|
||||
self.max_cmds = json.load(self._open("/api/status")).get("max_cmds", 24)
|
||||
status = json.load(self._open("/api/status"))
|
||||
self.max_cmds = status.get("max_cmds", 24)
|
||||
# Board::handleCommand() commands this node probed for at startup.
|
||||
self.board_cmds = [c for c in status.get("board_cmds", "").split(",") if c]
|
||||
|
||||
def _open(self, path, data=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -172,7 +233,7 @@ def main():
|
||||
|
||||
failures = []
|
||||
|
||||
cmds = table()
|
||||
cmds = table(cli.board_cmds)
|
||||
unexpected = []
|
||||
for cmd, res in cli.run(cmds):
|
||||
if res["ok"]:
|
||||
@@ -182,6 +243,7 @@ def main():
|
||||
continue
|
||||
unexpected.append((cmd, res["reply"]))
|
||||
print("commands offered by autocomplete : %d" % len(cmds))
|
||||
print("board commands this node answers : %s" % (", ".join(cli.board_cmds) or "none"))
|
||||
print("answered : %d" % (len(cmds) - len(unexpected)))
|
||||
print("sequence cap reported by the node: %d" % cli.max_cmds)
|
||||
for cmd, reply in unexpected:
|
||||
@@ -191,17 +253,39 @@ def main():
|
||||
# The reverse direction: a command the firmware implements but the table
|
||||
# never offers is invisible to the check above, because the check only ever
|
||||
# drives what the table already knows about.
|
||||
offered = " ".join(cmds) + " " + " ".join(
|
||||
re.findall(r'\["([^"]+)","', open(INDEX_HTML, encoding="utf-8").read()))
|
||||
# A board command is a whole `get`/`set` line, so the get-only list the audit
|
||||
# drives cannot decide it is offered: `set fan.lo` has no `get` counterpart in
|
||||
# the table at all, and a board key this mock does not claim is absent from
|
||||
# `cmds` while still being offerable. Both are expanded from the page here.
|
||||
html = open(INDEX_HTML, encoding="utf-8").read()
|
||||
keys = re.findall(r'\["([^"]+)","(?:[^"\\]|\\.)*",(\d)',
|
||||
html[html.index("var CLI_KEYS="):html.index("var CLI_BOARD_KEYS=")])
|
||||
offered = " ".join(cmds) + " " \
|
||||
+ " ".join("set " + k for k, mode in keys if mode != "1") + " " \
|
||||
+ " ".join("get %s set %s" % (k, k) for _, k, _ in board_table()) + " " \
|
||||
+ " ".join(re.findall(r'\["([^"]+)","', html))
|
||||
missing = sorted(c for c in firmware_commands() if c not in offered)
|
||||
print("\nfirmware commands not in the table: %d" % len(missing))
|
||||
for c in missing:
|
||||
print(" MISSING %s" % c)
|
||||
failures += [(c, "not offered by autocomplete") for c in missing]
|
||||
|
||||
results = cli.run([c for probe in ROUND_TRIPS for c in probe[:2]])
|
||||
print("\nround-trips : %d" % len(ROUND_TRIPS))
|
||||
for i, (setc, getc, want) in enumerate(ROUND_TRIPS):
|
||||
# The page asks the node for each gate by name (`get <gate>`), so a gate that
|
||||
# no board getter answers would hide its keys on every board, silently.
|
||||
fw = firmware_commands()
|
||||
gates = sorted({gate for gate, _, _ in board_table()})
|
||||
bad_gates = [g for g in gates if "get " + g not in fw]
|
||||
print("\nboard-command gates : %d" % len(gates))
|
||||
for g in bad_gates:
|
||||
print(" FAIL %-30s no board answers `get %s`" % (g, g))
|
||||
failures += [(g, "gate has no getter") for g in bad_gates]
|
||||
|
||||
probes = list(ROUND_TRIPS)
|
||||
for gate in cli.board_cmds:
|
||||
probes += BOARD_ROUND_TRIPS.get(gate, [])
|
||||
results = cli.run([c for probe in probes for c in probe[:2]])
|
||||
print("\nround-trips : %d" % len(probes))
|
||||
for i, (setc, getc, want) in enumerate(probes):
|
||||
setr, getr = results[i * 2][1], results[i * 2 + 1][1]
|
||||
# `get` answers "> value"; compare the value, as the terminal displays it
|
||||
got = re.sub(r"^>\s?", "", getr["reply"])
|
||||
@@ -214,7 +298,7 @@ def main():
|
||||
# Commands the portal refuses must be refused clearly, not run and fudged.
|
||||
print("\nrefused with a reason : ", end="")
|
||||
refused = []
|
||||
for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}):
|
||||
for cmd in sorted(NOT_OFFERED - NOT_REFUSED):
|
||||
try:
|
||||
cli._sequence([cmd])
|
||||
refused.append((cmd, "was accepted, expected a 400"))
|
||||
@@ -222,7 +306,8 @@ def main():
|
||||
body = json.load(e) if e.code == 400 else {}
|
||||
if e.code != 400 or not body.get("error"):
|
||||
refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code))
|
||||
print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1))
|
||||
checked = len(NOT_OFFERED) - len(NOT_REFUSED)
|
||||
print("%d/%d" % (checked - len(refused), checked))
|
||||
for cmd, why in refused:
|
||||
print(" FAIL %-30s %s" % (cmd, why))
|
||||
failures += refused
|
||||
|
||||
@@ -101,6 +101,9 @@ def default_config(setup_mode):
|
||||
"radio": {
|
||||
"freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0,
|
||||
"rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True,
|
||||
# Stored in NodePrefs like any other radio pref; only the COMMAND
|
||||
# is board-specific, so the value exists even where nothing drives it.
|
||||
"fem_rxgain": True, "fem_txgain": False,
|
||||
"repeat": True, "flood_max": 64, "flood_max_advert": 8,
|
||||
"flood_max_unscoped": 8, "loop_detect": "moderate",
|
||||
"name": "MockNode", "lat": 39.7392, "lon": -104.9903,
|
||||
@@ -149,6 +152,9 @@ class State:
|
||||
self.lock = threading.Lock()
|
||||
self.setup_mode = args.setup
|
||||
self.active_slots = args.active_slots
|
||||
# Board::handleCommand() commands this "board" answers. Empty by default:
|
||||
# the mock is a Heltec V3, which implements no such hook at all.
|
||||
self.board_cmds = [c for c in args.board_cmds.split(",") if c]
|
||||
self.cfg = default_config(args.setup)
|
||||
# latched at AP start, like WebConfigServer::_initial_setup
|
||||
self.initial_setup = args.setup and self.cfg["wifi"]["ssid"] == ""
|
||||
@@ -192,6 +198,7 @@ class State:
|
||||
"role": "Repeater", "board": "Heltec V3 (mock)",
|
||||
"uptime_s": int(time.time() - self.start),
|
||||
"runtime_slots": 6, "max_slots": 6, "active_slots": self.active_slots,
|
||||
"board_cmds": ",".join(self.board_cmds),
|
||||
"max_cmds": CLI_MAX_CMDS,
|
||||
}
|
||||
|
||||
@@ -253,9 +260,6 @@ def apply_set(cfg, key, val):
|
||||
ADMIN_PASSWORD = val
|
||||
return True, "OK"
|
||||
|
||||
if key == "radio.fem.rxgain":
|
||||
return False, "Error: unsupported" # no FEM on the mock board, see GETTERS
|
||||
|
||||
if key == "dutycycle":
|
||||
try:
|
||||
dc = float(val)
|
||||
@@ -340,13 +344,74 @@ def apply_set(cfg, key, val):
|
||||
sec, f = STR_KEYS[key]
|
||||
cfg[sec][f] = val
|
||||
return True, "OK"
|
||||
gate = BOARD_CMD_GATE.get(key)
|
||||
if gate:
|
||||
if gate not in ST.board_cmds:
|
||||
return False, "unknown config: %s" % key # no such command on this board
|
||||
return apply_board_set(cfg, key, val)
|
||||
|
||||
# Strict fallthrough: this function is the single authority on what can be
|
||||
# set, for the batch and the CLI alike. Accepting unknown keys here once hid
|
||||
# the fact that the CLI could not reach `dutycycle` or `radio.fem.rxgain`.
|
||||
# Verbatim shape from CommonCLI::handleSetCmd's fallthrough.
|
||||
#
|
||||
# This is also where the board-specific commands land. `radio.fem.*` and
|
||||
# `fan*` come from Board::handleCommand(), which is dispatched ahead of
|
||||
# CommonCLI; the mock board is a Heltec V3 and implements no such hook, so
|
||||
# they reach this fallthrough exactly as they do on the real thing.
|
||||
return False, "unknown config: %s" % key
|
||||
|
||||
|
||||
# Commands that reach Board::handleCommand() rather than CommonCLI. Which ones a
|
||||
# node answers is a property of the board, so the portal probes for them at
|
||||
# startup and reports the answers in /api/status; --board-cmds picks which ones
|
||||
# this mock claims. `fan.lo` / `fan.hi` have no getter and ride on `fan`.
|
||||
BOARD_CMD_GATE = {
|
||||
"radio.fem.rxgain": "radio.fem.rxgain",
|
||||
"radio.fem.txgain": "radio.fem.txgain",
|
||||
"fan": "fan",
|
||||
"fan.lo": "fan",
|
||||
"fan.hi": "fan",
|
||||
}
|
||||
BOARD_STATE = {"fan": "auto", "fan.lo": 45, "fan.hi": 60}
|
||||
FEM_FIELD = {"radio.fem.rxgain": "fem_rxgain", "radio.fem.txgain": "fem_txgain"}
|
||||
|
||||
|
||||
def apply_board_set(cfg, key, val):
|
||||
if key in FEM_FIELD:
|
||||
if val not in ("on", "off"):
|
||||
return False, "Error: state must be on or off"
|
||||
cfg["radio"][FEM_FIELD[key]] = val == "on"
|
||||
return True, "OK - LoRa FEM %s gain %s" % ("RX" if "rx" in key else "TX", val)
|
||||
if key == "fan":
|
||||
if val not in ("on", "off", "auto"):
|
||||
return False, "Error: fan must be on, off, or auto"
|
||||
BOARD_STATE["fan"] = val
|
||||
return True, "OK - fan %s" % val
|
||||
try:
|
||||
n = int(val)
|
||||
except ValueError:
|
||||
return False, "Error: expected a number"
|
||||
if key == "fan.lo" and not (0 <= n <= 100 and n < BOARD_STATE["fan.hi"]):
|
||||
return False, "Error: fan.lo must be 0..100 and < fan.hi"
|
||||
if key == "fan.hi" and not (BOARD_STATE["fan.lo"] < n <= 120):
|
||||
return False, "Error: fan.hi must be > fan.lo and <= 120"
|
||||
BOARD_STATE[key] = n
|
||||
return True, "OK - %s %d" % (key, n)
|
||||
|
||||
|
||||
def board_get(cfg, key):
|
||||
"""Getter reply, or None when this board does not answer the command."""
|
||||
if BOARD_CMD_GATE.get(key) not in ST.board_cmds:
|
||||
return None
|
||||
if key == "fan":
|
||||
return "%s 41.0C fan=%s cd=0s" % (
|
||||
BOARD_STATE["fan"], "on" if BOARD_STATE["fan"] == "on" else "off")
|
||||
if key in FEM_FIELD:
|
||||
return "on" if cfg["radio"][FEM_FIELD[key]] else "off"
|
||||
return None # fan.lo / fan.hi are set-only on the board too
|
||||
|
||||
|
||||
# Payload-type names accepted alongside the decimal form. Mirrors
|
||||
# namedPacketTypes() in src/helpers/MQTTPacketFilter.h; 12-14 are reserved
|
||||
# upstream and stay reachable by number only.
|
||||
@@ -516,10 +581,6 @@ GETTERS = {
|
||||
"mqtt.ntp.diag": lambda c: "last sync: 42s ago via %s (offset +0.011s)" % (c["mqtt"]["ntp"] or "none"),
|
||||
"mqtt.stats": lambda c: ("published: %d\ndropped: 0\nqueue: 0/24\nreconnects: 1"
|
||||
% (100 + int(time.time() - ST.start))),
|
||||
# Runtime-gated on the real device (Board::canControlLoRaFemLna), not
|
||||
# compiled out — the command exists everywhere and the board answers for
|
||||
# itself. The mock board is a Heltec V3, which has no FEM.
|
||||
"radio.fem.rxgain": lambda c: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -547,6 +608,11 @@ def cli_get(cfg, key):
|
||||
|
||||
|
||||
def _cli_get_value(cfg, key):
|
||||
if key in BOARD_CMD_GATE:
|
||||
val = board_get(cfg, key)
|
||||
# Not answered: the board has no hook for it, so CommonCLI's own getter
|
||||
# fallthrough is what replies — exactly as on the real thing.
|
||||
return (True, val) if val is not None else (False, "??: %s" % key)
|
||||
if key in GETTERS:
|
||||
val = GETTERS[key](cfg)
|
||||
return (True, val) if val is not None else (False, "Error: unsupported")
|
||||
@@ -1053,6 +1119,9 @@ def main():
|
||||
ap.add_argument("--port", type=int, default=8080)
|
||||
ap.add_argument("--setup", action="store_true", help="first-boot setup wizard mode")
|
||||
ap.add_argument("--active-slots", type=int, default=5, help="server slots to expose (2 or 5)")
|
||||
ap.add_argument("--board-cmds", default="",
|
||||
help="comma list of Board::handleCommand() commands to answer, e.g. "
|
||||
"radio.fem.rxgain,radio.fem.txgain,fan (default: none, like a Heltec V3)")
|
||||
ap.add_argument("--fw-version", default=FW_VERSION,
|
||||
help="version string to report, shaped like build.sh's embedded one")
|
||||
ap.add_argument("--minify", action="store_true",
|
||||
|
||||
Reference in New Issue
Block a user