584 lines
23 KiB
Python
Executable File
584 lines
23 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""What is installed, what Panama offers to install, and what a flatpak may do.
|
|
|
|
Three boundaries, deliberately separate:
|
|
|
|
flatpaks / permissions read-only questions about what is already here.
|
|
catalog the same optional-application catalog `panama apps`
|
|
and ./install read, parsed by the same rules.
|
|
install / uninstall mutations, each one validated against the catalog or
|
|
against what flatpak reports as installed.
|
|
|
|
Deliberately absent: removing dnf packages. A settings page that uninstalls
|
|
system packages is one mis-click away from removing the compositor it is drawn
|
|
by, and dnf's dependency resolution will happily take half the desktop with it.
|
|
The Applications page says "installed by the system package manager" and names
|
|
the command instead. There is no dnf removal path anywhere in this file, and the
|
|
contract checks for one.
|
|
|
|
The catalog is a closed surface: `install` refuses any entry that is not in
|
|
setup/packages/extras, so this helper can never become a way to install an
|
|
arbitrary package by passing a different string.
|
|
|
|
panama-applications flatpaks
|
|
panama-applications permissions APP_ID
|
|
panama-applications uninstall APP_ID
|
|
panama-applications unused-runtimes
|
|
panama-applications clean-unused
|
|
panama-applications catalog
|
|
panama-applications install CATEGORY ENTRY_ID
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Flathub ids, dnf package names, and category file names. Everything that
|
|
# reaches a command line is matched against one of these first.
|
|
APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$")
|
|
PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
|
|
CATEGORY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
|
|
# flatpak prints sizes for people, not for programs: there is no machine-readable
|
|
# column for the installed size in any released flatpak. The display string is
|
|
# kept as-is for the page, and the parsed number is only ever used for the
|
|
# storage breakdown, which says out loud that it is flatpak's own rounded figure.
|
|
SIZE_UNITS = {
|
|
"b": 1, "byte": 1, "bytes": 1,
|
|
"kb": 10**3, "mb": 10**6, "gb": 10**9, "tb": 10**12,
|
|
"kib": 1024, "mib": 1024**2, "gib": 1024**3, "tib": 1024**4,
|
|
}
|
|
SIZE = re.compile(r"^\s*([0-9]+(?:[.,][0-9]+)?)\s*([A-Za-z]+)\s*$")
|
|
|
|
# The remote every catalog flatpak comes from. install-packages adds it during
|
|
# setup; naming it here keeps a machine with several remotes from resolving an
|
|
# id against whichever one happens to be first.
|
|
FLATHUB = "flathub"
|
|
|
|
|
|
class BoundaryError(RuntimeError):
|
|
"""A user-visible validation or command failure."""
|
|
|
|
|
|
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
|
|
try:
|
|
return subprocess.run(command, capture_output=True, text=True,
|
|
timeout=timeout, check=False)
|
|
except subprocess.TimeoutExpired as error:
|
|
raise BoundaryError(f"{command[0]} did not answer in time.") from error
|
|
except OSError as error:
|
|
raise BoundaryError(f"{command[0]} is not available.") from error
|
|
|
|
|
|
def require_flatpak() -> None:
|
|
if not shutil.which("flatpak"):
|
|
raise BoundaryError("Flatpak is not installed on this machine.")
|
|
|
|
|
|
def parse_size(text: str) -> int | None:
|
|
"""flatpak's own size string as bytes, or nothing when it cannot be read.
|
|
|
|
Nothing is not zero. A size this cannot parse is reported as unmeasured so
|
|
the storage breakdown can say so, rather than quietly shrinking the
|
|
applications segment and inflating the remainder.
|
|
"""
|
|
match = SIZE.match(text or "")
|
|
if not match:
|
|
return None
|
|
number, unit = match.groups()
|
|
factor = SIZE_UNITS.get(unit.lower())
|
|
if factor is None:
|
|
return None
|
|
try:
|
|
return int(float(number.replace(",", ".")) * factor)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def flatpak_rows(scope: list[str], columns: list[str]) -> list[list[str]]:
|
|
"""Tab-separated `flatpak list` output as rows, or nothing when it fails."""
|
|
result = run(["flatpak", "list", *scope, "--columns=" + ",".join(columns)])
|
|
if result.returncode != 0:
|
|
return []
|
|
rows = []
|
|
for line in result.stdout.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
fields = line.split("\t")
|
|
# A column flatpak could not fill comes back empty rather than missing,
|
|
# but a future flatpak that drops one should not throw an IndexError
|
|
# across the whole page.
|
|
fields += [""] * (len(columns) - len(fields))
|
|
rows.append(fields[:len(columns)])
|
|
return rows
|
|
|
|
|
|
def flatpaks() -> list[dict]:
|
|
require_flatpak()
|
|
entries = []
|
|
for app_id, name, size, origin in flatpak_rows(
|
|
["--app"], ["application", "name", "size", "origin"]):
|
|
entries.append({
|
|
"id": app_id,
|
|
"name": name or app_id,
|
|
"size": size,
|
|
"sizeBytes": parse_size(size),
|
|
"origin": origin,
|
|
})
|
|
entries.sort(key=lambda entry: (entry["name"].casefold(), entry["id"]))
|
|
return entries
|
|
|
|
|
|
# ── Permissions ──────────────────────────────────────────────────────────────
|
|
#
|
|
# A flatpak's sandbox is described by a handful of keys whose values are
|
|
# semicolon-separated tokens. The buckets below are the questions people
|
|
# actually ask -- can it see my camera, can it read my files -- and everything
|
|
# that does not land in a bucket still gets a line. A permission summary that
|
|
# silently omits what it did not recognise is worse than no summary: it reads as
|
|
# "this app asks for nothing else".
|
|
|
|
CONTEXT_KEYS = ("shared", "sockets", "devices", "filesystems", "features", "persistent")
|
|
HOST_FILESYSTEMS = {"host", "host-os", "host-etc", "/"}
|
|
HOME_FILESYSTEMS = {"home", "~", "~/"}
|
|
DEVICE_LABELS = {
|
|
"all": "every device",
|
|
"dri": "graphics acceleration",
|
|
"kvm": "virtual machines",
|
|
"shm": "shared memory",
|
|
"input": "input devices",
|
|
"usb": "USB devices",
|
|
}
|
|
SOCKET_LABELS = {
|
|
"wayland": "Wayland",
|
|
"x11": "X11",
|
|
"fallback-x11": "X11 when Wayland is unavailable",
|
|
"session-bus": "the whole session bus",
|
|
"system-bus": "the whole system bus",
|
|
"ssh-auth": "your SSH agent",
|
|
"gpg-agent": "your GPG agent",
|
|
"cups": "printing",
|
|
"pcsc": "smart cards",
|
|
"inherit-wayland-socket": "an inherited Wayland socket",
|
|
}
|
|
|
|
|
|
def permission_sections(text: str) -> dict[str, dict[str, str]]:
|
|
parser = configparser.RawConfigParser(strict=False)
|
|
# Keys are lower-case already in the [Context] section, but bus names are
|
|
# not, and lower-casing org.gnome.Software would make the raw payload lie.
|
|
parser.optionxform = str
|
|
try:
|
|
parser.read_string(text)
|
|
except configparser.Error as error:
|
|
raise BoundaryError("That application's permissions could not be read.") from error
|
|
return {section: dict(parser.items(section)) for section in parser.sections()}
|
|
|
|
|
|
def tokens(value: str) -> list[str]:
|
|
return [item.strip() for item in (value or "").split(";") if item.strip()]
|
|
|
|
|
|
def base_path(entry: str) -> str:
|
|
"""`xdg-download:create` names the path `xdg-download`."""
|
|
return entry.split(":", 1)[0]
|
|
|
|
|
|
def join_some(items: list[str], limit: int = 4) -> str:
|
|
if len(items) <= limit:
|
|
return ", ".join(items)
|
|
return ", ".join(items[:limit]) + f" and {len(items) - limit} more"
|
|
|
|
|
|
def permission_summary(sections: dict[str, dict[str, str]]) -> list[str]:
|
|
context = sections.get("Context", {})
|
|
shared = tokens(context.get("shared", ""))
|
|
sockets = tokens(context.get("sockets", ""))
|
|
devices = tokens(context.get("devices", ""))
|
|
filesystems = tokens(context.get("filesystems", ""))
|
|
|
|
summary: list[str] = []
|
|
host = [item for item in filesystems if base_path(item) in HOST_FILESYSTEMS]
|
|
home = [item for item in filesystems if base_path(item) in HOME_FILESYSTEMS]
|
|
|
|
# Camera. Flatpak has no camera token: a webcam is reached through
|
|
# `devices=all`, so that is what this reports, and it says why.
|
|
if "all" in devices:
|
|
summary.append("Camera — full device access reaches webcams")
|
|
# Microphone. Likewise, audio is one permission in both directions.
|
|
if "pulseaudio" in sockets:
|
|
summary.append("Microphone — audio access records as well as plays")
|
|
if host:
|
|
summary.append("Full file system access")
|
|
if home:
|
|
summary.append("Home folder")
|
|
if "network" in shared:
|
|
summary.append("Network")
|
|
if devices:
|
|
summary.append("Devices: " + join_some(
|
|
[DEVICE_LABELS.get(item, item) for item in devices]))
|
|
|
|
# Everything the buckets did not claim, said plainly rather than dropped.
|
|
rest_shared = [item for item in shared if item != "network"]
|
|
if rest_shared:
|
|
summary.append("Also shares: " + join_some(rest_shared))
|
|
rest_sockets = [item for item in sockets if item != "pulseaudio"]
|
|
if rest_sockets:
|
|
# Sorted by the words shown rather than by flatpak's order, so
|
|
# "X11 when Wayland is unavailable" follows "X11" instead of leading it.
|
|
summary.append("Talks to " + join_some(
|
|
sorted(SOCKET_LABELS.get(item, item) for item in rest_sockets)))
|
|
rest_files = [item for item in filesystems if item not in host and item not in home]
|
|
if rest_files:
|
|
summary.append("Other locations: " + join_some(rest_files))
|
|
features = tokens(context.get("features", ""))
|
|
if features:
|
|
summary.append("Sandbox features: " + join_some(features))
|
|
persistent = tokens(context.get("persistent", ""))
|
|
if persistent:
|
|
summary.append("Keeps files in " + join_some(persistent))
|
|
|
|
for key, value in context.items():
|
|
if key in CONTEXT_KEYS:
|
|
continue
|
|
items = tokens(value)
|
|
summary.append(f"Also requests {key}: " + (join_some(items) if items else str(value)))
|
|
|
|
for name, entries in sections.items():
|
|
if name == "Context" or not entries:
|
|
continue
|
|
count = len(entries)
|
|
thing = "setting" if count == 1 else "settings"
|
|
summary.append(f"{name}: {count} {thing}")
|
|
|
|
if not summary:
|
|
summary.append("Nothing beyond the sandbox defaults")
|
|
return summary
|
|
|
|
|
|
def permissions(app_id: str) -> dict:
|
|
require_flatpak()
|
|
if not APP_ID.fullmatch(app_id or ""):
|
|
raise BoundaryError("That is not an application id.")
|
|
result = run(["flatpak", "info", "--show-permissions", app_id])
|
|
if result.returncode != 0:
|
|
raise BoundaryError("That application is not installed.")
|
|
sections = permission_sections(result.stdout)
|
|
return {"id": app_id, "summary": permission_summary(sections), "raw": sections}
|
|
|
|
|
|
# ── Mutations ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def installed_app_ids() -> set[str]:
|
|
return {row[0] for row in flatpak_rows(["--app"], ["application"]) if row[0]}
|
|
|
|
|
|
def uninstall(app_id: str) -> list[dict]:
|
|
require_flatpak()
|
|
if not APP_ID.fullmatch(app_id or ""):
|
|
raise BoundaryError("That is not an application id.")
|
|
# --app so a mistyped id can never resolve to a runtime, and the id is
|
|
# checked against what is actually installed rather than trusting the page.
|
|
if app_id not in installed_app_ids():
|
|
raise BoundaryError("That application is not installed.")
|
|
result = run(["flatpak", "uninstall", "--app", "--noninteractive", app_id], timeout=300)
|
|
if result.returncode != 0:
|
|
raise BoundaryError(_refusal(result, "That application could not be removed."))
|
|
return flatpaks()
|
|
|
|
|
|
def unused_runtimes() -> list[dict]:
|
|
"""Runtimes and extensions no installed application asks for.
|
|
|
|
This is an estimate, and the page says so: the removal itself is
|
|
`flatpak uninstall --unused`, which recomputes the list with flatpak's own
|
|
dependency graph. Showing a size before asking needs a number now, and
|
|
flatpak offers no way to ask what --unused would do without doing it.
|
|
"""
|
|
require_flatpak()
|
|
apps = flatpak_rows(["--app"], ["application", "runtime"])
|
|
runtimes = flatpak_rows(["--runtime"], ["application", "ref", "branch", "size", "runtime"])
|
|
|
|
# Seeded with the apps, so an application's own extensions -- OBS's plugins
|
|
# are installed as runtimes named com.obsproject.Studio.Plugin.* -- count as
|
|
# used rather than as sixteen orphans.
|
|
used: set[str] = {row[0] for row in apps if row[0]}
|
|
used |= {row[1].split("/")[0] for row in apps if row[1]}
|
|
used_refs: set[str] = {row[1] for row in apps if row[1]}
|
|
|
|
# An extension of something used is used, and a runtime's own runtime is
|
|
# used. Repeated until nothing new appears, because the chain can be two or
|
|
# three long (app -> Platform -> Platform.Locale).
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for app_id, ref, _branch, _size, runtime in runtimes:
|
|
if not app_id or app_id in used:
|
|
continue
|
|
parent = next((name for name in used
|
|
if app_id.startswith(name + ".")), None)
|
|
if parent is not None or ref in used_refs:
|
|
used.add(app_id)
|
|
if runtime:
|
|
used.add(runtime.split("/")[0])
|
|
used_refs.add(runtime)
|
|
changed = True
|
|
|
|
entries = []
|
|
for app_id, ref, branch, size, _runtime in runtimes:
|
|
if not app_id or app_id in used:
|
|
continue
|
|
entries.append({
|
|
"id": app_id,
|
|
"ref": ref,
|
|
"branch": branch,
|
|
"size": size,
|
|
"sizeBytes": parse_size(size),
|
|
})
|
|
entries.sort(key=lambda entry: entry["sizeBytes"] or 0, reverse=True)
|
|
return entries
|
|
|
|
|
|
def clean_unused() -> list[dict]:
|
|
require_flatpak()
|
|
result = run(["flatpak", "uninstall", "--unused", "--noninteractive"], timeout=600)
|
|
if result.returncode != 0:
|
|
raise BoundaryError(_refusal(result, "The unused runtimes could not be removed."))
|
|
return flatpaks()
|
|
|
|
|
|
# ── The catalog ──────────────────────────────────────────────────────────────
|
|
#
|
|
# The rules below are setup/lib/extras-catalog's, restated in Python because a
|
|
# QML page cannot source a bash library. They are pinned by a contract that
|
|
# reads both, so a change to one that is not made to the other fails loudly
|
|
# rather than producing a menu that installs something else.
|
|
#
|
|
# * everything from the first `#` onward is stripped, inline comments included
|
|
# * blank and whitespace-only lines are skipped, after stripping
|
|
# * a line beginning with whitespace continues the entry above it and is
|
|
# installed with it, never listed on its own
|
|
# * `target | label`, split on the first `|`, both sides trimmed
|
|
# * with no label, a `flatpak:` id becomes its last dotted component and a dnf
|
|
# package keeps its own name
|
|
# * installed means `flatpak info <id>` for a flatpak and `rpm -q <name>`
|
|
# otherwise -- here, membership in one listing of each rather than a process
|
|
# per entry, which is the same question asked cheaply
|
|
|
|
|
|
def extras_directory() -> Path:
|
|
override = os.environ.get("PANAMA_EXTRAS_DIR")
|
|
if override:
|
|
return Path(override)
|
|
return Path(__file__).resolve().parents[4] / "setup" / "packages" / "extras"
|
|
|
|
|
|
def category_files() -> list[Path]:
|
|
directory = extras_directory()
|
|
if not directory.is_dir():
|
|
raise BoundaryError("The application catalog is not available.")
|
|
return sorted(path for path in directory.iterdir()
|
|
if path.is_file() and CATEGORY_NAME.fullmatch(path.name))
|
|
|
|
|
|
def category_file(name: str) -> Path:
|
|
if not CATEGORY_NAME.fullmatch(name or ""):
|
|
raise BoundaryError("That is not an application category.")
|
|
for path in category_files():
|
|
if path.name == name:
|
|
return path
|
|
raise BoundaryError("That is not an application category.")
|
|
|
|
|
|
def catalog_lines(path: Path) -> list[str]:
|
|
try:
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as error:
|
|
raise BoundaryError("The application catalog could not be read.") from error
|
|
return [line.split("#", 1)[0] for line in text.splitlines()]
|
|
|
|
|
|
def split_entry(line: str) -> tuple[str, str]:
|
|
target, _, label = line.partition("|")
|
|
target = target.strip()
|
|
label = label.strip()
|
|
if not label:
|
|
label = target[len("flatpak:"):] if target.startswith("flatpak:") else target
|
|
if target.startswith("flatpak:"):
|
|
label = label.rsplit(".", 1)[-1]
|
|
return target, label
|
|
|
|
|
|
def catalog_entries(path: Path) -> list[tuple[str, str]]:
|
|
entries = []
|
|
for line in catalog_lines(path):
|
|
if not line.strip() or line[:1].isspace():
|
|
continue
|
|
target, label = split_entry(line)
|
|
if target:
|
|
entries.append((target, label))
|
|
return entries
|
|
|
|
|
|
def catalog_targets(path: Path, wanted: str) -> list[str]:
|
|
"""The entry itself, then the indented lines beneath it."""
|
|
targets: list[str] = []
|
|
seen = False
|
|
for line in catalog_lines(path):
|
|
if not line.strip():
|
|
continue
|
|
if line[:1].isspace():
|
|
if seen:
|
|
targets.append(line.split("|", 1)[0].strip())
|
|
continue
|
|
target, _ = split_entry(line)
|
|
if target == wanted:
|
|
seen = True
|
|
targets.append(target)
|
|
elif seen:
|
|
break
|
|
return [target for target in targets if target]
|
|
|
|
|
|
def installed_sets() -> tuple[set[str], set[str]]:
|
|
"""Every installed flatpak ref name, and every installed rpm name.
|
|
|
|
One listing each rather than `flatpak info` and `rpm -q` per entry: the
|
|
catalog runs to a hundred entries and the page opens with it.
|
|
"""
|
|
flatpak_ids: set[str] = set()
|
|
if shutil.which("flatpak"):
|
|
flatpak_ids = {row[0] for row in flatpak_rows([], ["application"]) if row[0]}
|
|
packages: set[str] = set()
|
|
if shutil.which("rpm"):
|
|
result = run(["rpm", "-qa", "--qf", "%{NAME}\\n"], timeout=60)
|
|
if result.returncode == 0:
|
|
packages = {line.strip() for line in result.stdout.splitlines() if line.strip()}
|
|
return flatpak_ids, packages
|
|
|
|
|
|
def entry_payload(target: str, label: str,
|
|
flatpak_ids: set[str], packages: set[str]) -> dict:
|
|
is_flatpak = target.startswith("flatpak:")
|
|
ref = target[len("flatpak:"):] if is_flatpak else target
|
|
return {
|
|
# The id is the catalog line's target, verbatim, because that is what
|
|
# `install` matches and what extras-catalog's own lookup takes.
|
|
"id": target,
|
|
"ref": ref,
|
|
"label": label,
|
|
"kind": "flatpak" if is_flatpak else "dnf",
|
|
"installed": ref in (flatpak_ids if is_flatpak else packages),
|
|
}
|
|
|
|
|
|
def catalog() -> dict:
|
|
flatpak_ids, packages = installed_sets()
|
|
categories = []
|
|
for path in category_files():
|
|
entries = [entry_payload(target, label, flatpak_ids, packages)
|
|
for target, label in catalog_entries(path)]
|
|
categories.append({
|
|
"name": path.name,
|
|
# "gpu-compute" is a file name; "GPU compute" is a heading. The
|
|
# page needs the second and the helper needs the first.
|
|
"label": path.name.replace("-", " ").capitalize(),
|
|
"entries": entries,
|
|
})
|
|
return {"categories": categories}
|
|
|
|
|
|
def install(category: str, entry_id: str) -> dict:
|
|
"""Install one catalog entry, and nothing that is not a catalog entry."""
|
|
path = category_file(category)
|
|
known = {target for target, _ in catalog_entries(path)}
|
|
if entry_id not in known:
|
|
raise BoundaryError("That application is not in the catalog.")
|
|
|
|
flatpak_targets: list[str] = []
|
|
dnf_targets: list[str] = []
|
|
for target in catalog_targets(path, entry_id):
|
|
if target.startswith("flatpak:"):
|
|
identifier = target[len("flatpak:"):]
|
|
if not APP_ID.fullmatch(identifier):
|
|
raise BoundaryError("That catalog entry names something unusable.")
|
|
flatpak_targets.append(identifier)
|
|
else:
|
|
if not PACKAGE_NAME.fullmatch(target):
|
|
raise BoundaryError("That catalog entry names something unusable.")
|
|
dnf_targets.append(target)
|
|
|
|
if not flatpak_targets and not dnf_targets:
|
|
raise BoundaryError("That catalog entry installs nothing.")
|
|
|
|
if flatpak_targets:
|
|
require_flatpak()
|
|
result = run(["flatpak", "install", "--noninteractive", FLATHUB, *flatpak_targets],
|
|
timeout=1800)
|
|
if result.returncode != 0:
|
|
raise BoundaryError(_refusal(result, "That application could not be installed."))
|
|
if dnf_targets:
|
|
if not shutil.which("dnf"):
|
|
raise BoundaryError("dnf is not available on this machine.")
|
|
# pkexec rather than sudo: the desktop already runs a polkit agent, and
|
|
# a settings page has no terminal to type a password into.
|
|
result = run(["pkexec", "dnf", "install", "-y", *dnf_targets], timeout=1800)
|
|
if result.returncode != 0:
|
|
raise BoundaryError(_refusal(result, "That package could not be installed."))
|
|
return catalog()
|
|
|
|
|
|
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
|
|
text = (result.stderr or result.stdout or "").strip().splitlines()
|
|
if not text:
|
|
return fallback
|
|
last = text[-1].strip()
|
|
lowered = last.lower()
|
|
if "not authorized" in lowered or "dismissed" in lowered:
|
|
return "That change was not authorized."
|
|
return last[:200] or fallback
|
|
|
|
|
|
def emit(payload) -> None:
|
|
print(json.dumps(payload, separators=(",", ":")))
|
|
|
|
|
|
def main(arguments: list[str]) -> int:
|
|
try:
|
|
if arguments == ["flatpaks"]:
|
|
emit(flatpaks())
|
|
elif arguments == ["catalog"]:
|
|
emit(catalog())
|
|
elif arguments == ["unused-runtimes"]:
|
|
emit(unused_runtimes())
|
|
elif arguments == ["clean-unused"]:
|
|
emit(clean_unused())
|
|
elif len(arguments) == 2 and arguments[0] == "permissions":
|
|
emit(permissions(arguments[1]))
|
|
elif len(arguments) == 2 and arguments[0] == "uninstall":
|
|
emit(uninstall(arguments[1]))
|
|
elif len(arguments) == 3 and arguments[0] == "install":
|
|
emit(install(arguments[1], arguments[2]))
|
|
else:
|
|
raise BoundaryError(
|
|
"Usage: panama-applications flatpaks | permissions APP_ID | "
|
|
"uninstall APP_ID | unused-runtimes | clean-unused | catalog | "
|
|
"install CATEGORY ENTRY_ID")
|
|
except BoundaryError as error:
|
|
print(str(error), file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|