diff options
Diffstat (limited to 'packaging')
| -rw-r--r-- | packaging/README.md | 50 | ||||
| -rw-r--r-- | packaging/audit_deps.py | 131 | ||||
| -rwxr-xr-x | packaging/build/build-cli.sh | 91 | ||||
| -rwxr-xr-x | packaging/build/build-common.sh | 26 | ||||
| -rwxr-xr-x | packaging/build/build-node.sh | 22 | ||||
| -rwxr-xr-x | packaging/build/build-packages.sh | 16 | ||||
| -rw-r--r-- | packaging/conf/hub.toml.example | 41 | ||||
| -rw-r--r-- | packaging/deb/meshbay-cli/DEBIAN/control | 18 | ||||
| -rwxr-xr-x | packaging/deb/meshbay-cli/DEBIAN/postinst | 17 | ||||
| -rwxr-xr-x | packaging/deb/meshbay-cli/DEBIAN/prerm | 18 | ||||
| -rw-r--r-- | packaging/pip-audit-ignore.txt | 6 | ||||
| -rw-r--r-- | packaging/rpm/meshbay-cli.spec | 72 | ||||
| -rw-r--r-- | packaging/win/README.md | 20 | ||||
| -rw-r--r-- | packaging/win/build-node-runtime.ps1 | 62 | ||||
| -rw-r--r-- | packaging/win/cli-entry.py | 14 | ||||
| -rw-r--r-- | packaging/win/firewall.ps1 | 10 | ||||
| -rw-r--r-- | packaging/win/meshbay-node.spec | 127 |
17 files changed, 648 insertions, 93 deletions
diff --git a/packaging/README.md b/packaging/README.md index 6081fc7..8156ca0 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,12 +1,13 @@ # MeshBay — Packaging -Four packages, installed under `/opt/<PACKAGE_NAME>/`: +Five packages, installed under `/opt/<PACKAGE_NAME>/`: | Package | RPM spec | DEB control | Description | |---|---|---|---| | `meshbay-common` | `rpm/meshbay-common.spec` | `deb/meshbay-common/` | Shared Python venv + all pip deps | | `meshbay-hub` | `rpm/meshbay-hub.spec` | `deb/meshbay-hub/` | Hub server (FastAPI + PostgreSQL) | | `meshbay-node` | `rpm/meshbay-node.spec` | `deb/meshbay-node/` | Node daemon + local UI | +| `meshbay-cli` | `rpm/meshbay-cli.spec` | `deb/meshbay-cli/` | Command-line client (a member's files from a terminal) + its man page | | `meshbay-client` | `rpm/meshbay-client.spec` | `deb/meshbay-client/` | Electron desktop app | ## Building packages @@ -22,7 +23,7 @@ bash packaging/build/build-packages.sh rpm bash packaging/build/build-packages.sh deb --arm64 ``` -Output: `meshbay-{common,hub,node,client}_<version>_{arch}.{deb,rpm}` in +Output: `meshbay-{common,hub,node,cli,client}_<version>_{arch}.{deb,rpm}` in `/tmp/meshbay-build/out/` (override with `MESHBAY_BUILD_DIR`). The version comes from each package's `pyproject.toml`. @@ -33,12 +34,12 @@ Always install `meshbay-common` first (owns the shared Python venv). ```bash # Ubuntu/Debian sudo dpkg -i meshbay-common_*.deb -sudo dpkg -i meshbay-hub_*.deb meshbay-node_*.deb +sudo dpkg -i meshbay-hub_*.deb meshbay-node_*.deb meshbay-cli_*.deb sudo dpkg -i meshbay-client_*.deb # Fedora sudo rpm -ivh meshbay-common-*.rpm -sudo rpm -ivh meshbay-hub-*.rpm meshbay-node-*.rpm +sudo rpm -ivh meshbay-hub-*.rpm meshbay-node-*.rpm meshbay-cli-*.rpm sudo rpm -ivh meshbay-client-*.rpm ``` @@ -75,8 +76,45 @@ already remembers the hub address — under `videoDecodeProbe`, and ## Architecture meshbay-common owns the shared Python virtual environment at -`/opt/meshbay-common/venv/`. Hub and node install their code into that venv. -The client is a standalone Electron app at `/opt/meshbay-client/`. +`/opt/meshbay-common/venv/`. Hub, node and command-line client install their +code into that venv. The client is a standalone Electron app at +`/opt/meshbay-client/`. + +### meshbay-cli + +The command-line client is its own package, for a desktop that wants it and a +server that does not. `build-common.sh` installs it into the venv last, after +listing what the hub and the node need (`servers-site.txt`, +`servers-bin.txt` in the staging directory); `build-cli.sh` moves whatever that +install added — the client and the packages only it uses (Textual, Rich, +keyring, argon2-cffi, ...) — into `meshbay-cli`, so a server carries none of +them. The list of what moved is installed as `/opt/meshbay-cli/site-packages.list`: +the maintainer scripts compile those files once at install (an unprivileged +user cannot write bytecode into `/opt`, and Textual is slow to compile on every +run) and remove that bytecode before removal. The rpm's `%files` takes the +same entries from a list `build-cli.sh` writes (`meshbay-cli-root.files`), +since they change with every dependency upgrade. + +Its architecture is the machine's, not `all`: argon2-cffi's bindings are +compiled code. Its licence notices are in +`/opt/meshbay-cli/THIRD-PARTY-NOTICES.txt`. + +### Known vulnerabilities + +No lock file pins the Python dependencies: every build installs the newest +releases, so what ships is only known once a build has installed it. That is +where [`audit_deps.py`](audit_deps.py) runs pip-audit — `build-common.sh` on the +venv before stripping it, `build-node-runtime.ps1` on the environment before +PyInstaller freezes it. A known vulnerability stops the build, unless its id is +in [`pip-audit-ignore.txt`](pip-audit-ignore.txt) with why it does not apply. +No network only skips the audit, with a warning. pip-audit itself runs from a +venv of its own (`<staging>/pip-audit`, `packages/meshbay-client/build/_pip-audit` +on Windows), never from the one that ships. + +The man page, `meshbay-cli(1)`, is generated at build time from the client's +own argument parser (`python -m meshbay_cli.manpage`), so it lists the options +the program has; `tests/test_manpage.py` fails when an action or option misses +its section, and checks that troff reads it without a warning. No pip runs at install time — everything is pre-built. diff --git a/packaging/audit_deps.py b/packaging/audit_deps.py new file mode 100644 index 0000000..513b1dd --- /dev/null +++ b/packaging/audit_deps.py @@ -0,0 +1,131 @@ +""" +Audit the Python environment a build is about to ship against known +vulnerabilities, with pip-audit. + + python audit_deps.py [--tool-dir DIR] + +Run with the interpreter of the environment being shipped: what it lists is +what is installed beside it. The builds resolve the newest releases when they +run (there is no lock file), so this is the one moment the shipped set is known. + +- A known vulnerability stops the build (exit 1), unless its id is listed in + packaging/pip-audit-ignore.txt with the reason it does not apply. +- No network is not a reason to stop: the audit is skipped with a warning and + the build goes on (exit 0). So is a pip-audit that cannot be installed or + produces no report, which is what a network failing halfway looks like. + +pip-audit runs from a venv of its own (--tool-dir, kept between builds), never +from the shipped environment: nothing it pulls in may end up in a package. +Our own packages are left out — they are not on PyPI — and so are pip, +setuptools and wheel, which install the environment and do not ship in it: +build-common.sh strips them from the venv and PyInstaller does not freeze them. +""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import urllib.request +from importlib import metadata +from pathlib import Path + +PIP_AUDIT = "pip-audit==2.10.1" +# What pip-audit asks by default; reachable means the audit can run. +PROBE = "https://pypi.org/pypi/pip-audit/json" +IGNORE_FILE = Path(__file__).resolve().parent / "pip-audit-ignore.txt" +NOT_SHIPPED = {"pip", "setuptools", "wheel"} + + +def warn(msg: str) -> None: + print(f"!! pip-audit skipped: {msg}", file=sys.stderr) + + +def shipped() -> list[str]: + seen: dict[str, str] = {} + for dist in metadata.distributions(): + name = (dist.metadata["Name"] or "").strip() + if name and not name.lower().startswith("meshbay") and name.lower() not in NOT_SHIPPED: + seen.setdefault(name.lower(), f"{name}=={dist.version}") + return sorted(seen.values()) + + +def ignored() -> list[str]: + if not IGNORE_FILE.exists(): + return [] + ids = [] + for line in IGNORE_FILE.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0].strip() + if line: + ids.append(line.split()[0]) + return ids + + +def tool(tool_dir: Path) -> Path | None: + exe = tool_dir / ("Scripts/pip-audit.exe" if os.name == "nt" else "bin/pip-audit") + py = tool_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if not py.exists(): + if subprocess.run([sys.executable, "-m", "venv", "--clear", str(tool_dir)], + capture_output=True).returncode != 0: + return None + r = subprocess.run([str(py), "-m", "pip", "install", "--quiet", + "--disable-pip-version-check", PIP_AUDIT], + capture_output=True, text=True) + return exe if r.returncode == 0 and exe.exists() else None + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--tool-dir", type=Path, + default=Path(tempfile.gettempdir()) / "meshbay-pip-audit") + args = ap.parse_args() + + try: + urllib.request.urlopen(PROBE, timeout=10).close() + except Exception as e: + warn(f"no network ({e.__class__.__name__}: {e})") + return 0 + + exe = tool(args.tool_dir) + if exe is None: + warn(f"could not install {PIP_AUDIT}") + return 0 + + with tempfile.TemporaryDirectory() as tmp: + req = Path(tmp) / "shipped.txt" + req.write_text("\n".join(shipped()) + "\n", encoding="utf-8") + report = Path(tmp) / "report.json" + cmd = [str(exe), "-r", str(req), "--no-deps", "--disable-pip", + "--progress-spinner", "off", "-f", "json", "-o", str(report)] + for vid in ignored(): + cmd += ["--ignore-vuln", vid] + run = subprocess.run(cmd, capture_output=True, text=True) + try: + data = json.loads(report.read_text(encoding="utf-8")) + except (OSError, ValueError): + tail = (run.stderr or run.stdout).strip().splitlines()[-3:] + warn("no report — " + " | ".join(tail)) + return 0 + + # The same id can come back twice, from two advisory sources. + found: dict[tuple[str, str, str], str] = {} + for d in data.get("dependencies", []): + for v in d.get("vulns", []): + found.setdefault((d["name"], d["version"], v["id"]), + ", ".join(v.get("fix_versions") or []) or "none") + audited = sum(1 for d in data.get("dependencies", []) if "skip_reason" not in d) + if not found: + print(f" pip-audit: {audited} packages, no known vulnerability") + return 0 + print(f"!! pip-audit: {len(found)} known vulnerabilit{'y' if len(found) == 1 else 'ies'}:", + file=sys.stderr) + for (name, version, vid), fix in found.items(): + print(f"!! {name} {version} {vid} (fixed in: {fix})", file=sys.stderr) + print(f"!! Upgrade, or list the id in {IGNORE_FILE.name} with why it does not apply.", + file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/build/build-cli.sh b/packaging/build/build-cli.sh new file mode 100755 index 0000000..b6bd563 --- /dev/null +++ b/packaging/build/build-cli.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Build the meshbay-cli package: the command-line client, and the Python +# packages only it needs, on top of meshbay-common. +# +# Expects build-common.sh to have run first: it installed the CLI last, after +# listing the servers' packages in servers-site.txt and servers-bin.txt, so +# everything not in those lists is the CLI's and moves here. +# +# Usage: ./build-cli.sh [staging-dir] +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +STAGING="${1:-/tmp/meshbay-build}" +COMMON_ROOT="$STAGING/meshbay-common-root" +ROOT="$STAGING/meshbay-cli-root" + +VENV="$COMMON_ROOT/opt/meshbay-common/venv" +PYVER=$(basename "$VENV"/lib/python3.*) +SITE="$VENV/lib/$PYVER/site-packages" + +VERSION=$(python3 -c " +import tomllib, pathlib +p = pathlib.Path('$REPO/packages/meshbay-cli/pyproject.toml') +print(tomllib.loads(p.read_text())['project']['version']) +") +echo "==> meshbay-cli $VERSION" + +[ -d "$VENV" ] || { echo "!! shared venv not found — run build-common.sh first" >&2; exit 1; } +[ -f "$STAGING/servers-site.txt" ] || { + echo "!! $STAGING/servers-site.txt missing — run build-common.sh first" >&2; exit 1; } +[ -d "$SITE/meshbay_cli" ] || { + echo "!! meshbay_cli not in the shared venv — was build-cli.sh already run?" >&2; exit 1; } + +rm -rf "$ROOT" +mkdir -p "$ROOT/opt/meshbay-cli" + +# Run from the staged venv with no bytecode written: whatever it imports +# would otherwise leave __pycache__ in files other packages own. +run_venv() { PYTHONDONTWRITEBYTECODE=1 "$VENV/bin/python" "$@"; } + +# --- Before moving anything: the notices and the man page ----------------- +# Both need the CLI importable, which it is only where build-common.sh put it. +echo " writing THIRD-PARTY-NOTICES.txt" +run_venv "$REPO/packaging/third_party_notices.py" \ + -o "$ROOT/opt/meshbay-cli/THIRD-PARTY-NOTICES.txt" meshbay-cli + +echo " generating the man page" +mkdir -p "$ROOT/usr/share/man/man1" +run_venv -m meshbay_cli.manpage | gzip -9n > "$ROOT/usr/share/man/man1/meshbay-cli.1.gz" +[ "$(gzip -dc "$ROOT/usr/share/man/man1/meshbay-cli.1.gz" | head -c 3)" = ".TH" ] || { + echo "!! the man page did not generate" >&2; exit 1; } + +# bash-completion loads it the first time Tab is pressed after `meshbay-cli`. +echo " generating the bash completion" +COMPLETIONS="$ROOT/usr/share/bash-completion/completions" +mkdir -p "$COMPLETIONS" +MESHBAY_CLI_HOME="$STAGING/cli-home" run_venv -m meshbay_cli completion bash \ + > "$COMPLETIONS/meshbay-cli" +grep -q '^complete -F _meshbay_cli meshbay-cli$' "$COMPLETIONS/meshbay-cli" || { + echo "!! the bash completion did not generate" >&2; exit 1; } + +# --- The CLI and its own dependencies, out of the common tree --------------- +# MOVE (not copy), as build-hub.sh and build-node.sh do: dpkg/rpm then know +# these files belong to meshbay-cli. The list is kept for the maintainer +# scripts, which remove the bytecode compiled next to these files. +CLI_SITE="$ROOT/opt/meshbay-common/venv/lib/$PYVER/site-packages" +CLI_BIN="$ROOT/opt/meshbay-common/venv/bin" +mkdir -p "$CLI_SITE" "$CLI_BIN" +: > "$ROOT/opt/meshbay-cli/site-packages.list" +while IFS= read -r entry; do + mv "$SITE/$entry" "$CLI_SITE/" + echo "$entry" >> "$ROOT/opt/meshbay-cli/site-packages.list" +done < <(LC_ALL=C comm -13 "$STAGING/servers-site.txt" <(LC_ALL=C ls -1A "$SITE")) +while IFS= read -r entry; do + mv "$VENV/bin/$entry" "$CLI_BIN/" +done < <(LC_ALL=C comm -13 "$STAGING/servers-bin.txt" <(LC_ALL=C ls -1A "$VENV/bin")) +echo " moved: $(tr '\n' ' ' < "$ROOT/opt/meshbay-cli/site-packages.list")" + +# Only the client is on PATH; the dependencies' own scripts (pygmentize, +# keyring, ...) stay inside the venv. +mkdir -p "$ROOT/usr/bin" +ln -sf /opt/meshbay-common/venv/bin/meshbay-cli "$ROOT/usr/bin/meshbay-cli" + +find "$ROOT" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +# What the venv holds of this package, for the rpm spec's %files: the entries +# differ with every dependency upgrade, so no list is kept by hand. +( cd "$ROOT" && find opt/meshbay-common/venv/lib/*/site-packages opt/meshbay-common/venv/bin \ + -mindepth 1 -maxdepth 1 | LC_ALL=C sort | sed 's|^|/|' ) > "$ROOT.files" + +echo "==> meshbay-cli staging ready at $ROOT" diff --git a/packaging/build/build-common.sh b/packaging/build/build-common.sh index 5f119c6..ca5bd1b 100755 --- a/packaging/build/build-common.sh +++ b/packaging/build/build-common.sh @@ -29,9 +29,9 @@ echo " creating venv at $VENV_BUILD" python3 -m venv "$VENV_BUILD" "$VENV_BUILD/bin/pip" install --upgrade pip wheel 2>&1 | tail -1 -# --- Build wheels for all three packages ----------------------------------- +# --- Build wheels for every package ---------------------------------------- echo " building wheels" -for pkg in meshbay-common meshbay-hub meshbay-node; do +for pkg in meshbay-common meshbay-hub meshbay-node meshbay-cli; do "$VENV_BUILD/bin/pip" wheel \ --no-deps \ --wheel-dir "$WHEEL_DIR" \ @@ -44,11 +44,29 @@ echo " installing all packages + dependencies" --find-links "$WHEEL_DIR" \ meshbay-common meshbay-hub meshbay-node 2>&1 | tail -3 +# The command-line client goes in last, after a list of what the servers need: +# whatever its install adds is the CLI's alone, and build-cli.sh moves it into +# the meshbay-cli package, so a server carries no Textual. +SITE_BUILD=$(echo "$VENV_BUILD"/lib/python3.*/site-packages) +LC_ALL=C ls -1A "$SITE_BUILD" > "$STAGING/servers-site.txt" +LC_ALL=C ls -1A "$VENV_BUILD/bin" > "$STAGING/servers-bin.txt" +echo " installing meshbay-cli + its own dependencies" +"$VENV_BUILD/bin/pip" install \ + --find-links "$WHEEL_DIR" \ + meshbay-cli 2>&1 | tail -1 + # --- Third-party notices: every package the venv ships, with its licence ------- echo " writing THIRD-PARTY-NOTICES.txt" "$VENV_BUILD/bin/python" "$REPO/packaging/third_party_notices.py" \ -o "$ROOT/opt/meshbay-common/THIRD-PARTY-NOTICES.txt" meshbay-hub meshbay-node +# --- Known vulnerabilities in what ships ----------------------------------- +# One stops the build unless packaging/pip-audit-ignore.txt says why it does not +# apply; no network only skips the audit, with a warning. pip-audit runs from a +# venv of its own under the staging dir, never from the one shipped. +echo " auditing dependencies (pip-audit)" +"$VENV_BUILD/bin/python" "$REPO/packaging/audit_deps.py" --tool-dir "$STAGING/pip-audit" + # --- Strip build tools from the venv (not needed at runtime) --------------- echo " stripping build tools" "$VENV_BUILD/bin/pip" uninstall -y pip setuptools wheel 2>&1 | tail -1 @@ -64,7 +82,9 @@ for f in "$VENV_BUILD/bin"/*; do [ -f "$f" ] || continue [ -L "$f" ] && continue head -1 "$f" | grep -q "^#!" || continue - sed -i "1s|$ROOT||" "$f" + # Line 2 too: past 127 characters, pip writes a `#!/bin/sh` trampoline that + # names the interpreter on the next line. + sed -i "1,2s|$ROOT||" "$f" done sed -i "s|$ROOT||g" "$VENV_BUILD/pyvenv.cfg" diff --git a/packaging/build/build-node.sh b/packaging/build/build-node.sh index b9d542c..c7e7577 100755 --- a/packaging/build/build-node.sh +++ b/packaging/build/build-node.sh @@ -45,18 +45,22 @@ ln -sf /opt/meshbay-common/venv/bin/meshbay-node "$ROOT/usr/bin/meshbay-node" # --- Node-specific assets ------------------------------------------------- mkdir -p "$ROOT/opt/meshbay-node/share" -# default.env: the shared TMDB token, copied as is from QE/default.env (never -# versioned), one line: MESHBAY_TMDB_DEFAULT_TOKEN=eyJ... The daemon reads it -# beneath <config>/node.env and the operator's own token, so it is only the -# fallback. 0644: a per-user node must read it, and it is the same token in -# every copy of the package. No token, no build: an empty one goes unnoticed. +# default.env: the shared TMDB token, taken from QE/default.env (never +# versioned). Only its first line ships, and only once it is exactly +# MESHBAY_TMDB_DEFAULT_TOKEN=<JWT>: anything else written in that file stays +# out of a public package. The daemon reads it beneath <config>/node.env and +# the operator's own token, so it is only the fallback. 0644: a per-user node +# must read it, and it is the same token in every copy of the package. No +# token, no build: an empty one goes unnoticed. DEFAULT_ENV="$REPO/QE/default.env" -if [ "$(head -c 30 "$DEFAULT_ENV" 2>/dev/null)" != "MESHBAY_TMDB_DEFAULT_TOKEN=eyJ" ]; then - echo "!! $DEFAULT_ENV missing, or not starting with MESHBAY_TMDB_DEFAULT_TOKEN=eyJ... (no BOM)" >&2 +TOKEN_LINE="$(head -n 1 "$DEFAULT_ENV" 2>/dev/null | tr -d '\r')" +if ! [[ "$TOKEN_LINE" =~ ^MESHBAY_TMDB_DEFAULT_TOKEN=eyJ[A-Za-z0-9_.-]+$ ]]; then + echo "!! $DEFAULT_ENV missing, or its first line is not MESHBAY_TMDB_DEFAULT_TOKEN=eyJ... (no BOM)" >&2 exit 1 fi -install -m 644 "$DEFAULT_ENV" "$ROOT/opt/meshbay-node/share/default.env" -echo " default.env copied from QE/" +printf '%s\n' "$TOKEN_LINE" > "$ROOT/opt/meshbay-node/share/default.env" +chmod 644 "$ROOT/opt/meshbay-node/share/default.env" +echo " default.env: token line taken from QE/" # --- Systemd units -------------------------------------------------------- mkdir -p "$ROOT/usr/lib/systemd/system" diff --git a/packaging/build/build-packages.sh b/packaging/build/build-packages.sh index 8ca0e23..7bbf6b5 100755 --- a/packaging/build/build-packages.sh +++ b/packaging/build/build-packages.sh @@ -63,19 +63,23 @@ mkdir -p "$OUT" # --- Step 1: Build staging trees ------------------------------------------ -echo "--- [1/4] meshbay-common (shared venv + all deps) ---" +echo "--- [1/5] meshbay-common (shared venv + all deps) ---" bash "$SCRIPT_DIR/build-common.sh" "$STAGING" echo "" -echo "--- [2/4] meshbay-hub ---" +echo "--- [2/5] meshbay-hub ---" bash "$SCRIPT_DIR/build-hub.sh" "$STAGING" echo "" -echo "--- [3/4] meshbay-node ---" +echo "--- [3/5] meshbay-node ---" bash "$SCRIPT_DIR/build-node.sh" "$STAGING" echo "" -echo "--- [4/4] meshbay-client ---" +echo "--- [4/5] meshbay-cli ---" +bash "$SCRIPT_DIR/build-cli.sh" "$STAGING" + +echo "" +echo "--- [5/5] meshbay-client ---" bash "$SCRIPT_DIR/build-client.sh" "$STAGING" $ARM64_FLAG # --- Step 2: Package ------------------------------------------------------ @@ -143,6 +147,7 @@ if [ "$FORMAT" = "deb" ]; then build_deb meshbay-common build_deb meshbay-hub build_deb meshbay-node + build_deb meshbay-cli build_deb meshbay-client elif [ "$FORMAT" = "rpm" ]; then @@ -175,6 +180,7 @@ elif [ "$FORMAT" = "rpm" ]; then build_rpm meshbay-common build_rpm meshbay-hub build_rpm meshbay-node + build_rpm meshbay-cli build_rpm meshbay-client fi @@ -187,5 +193,5 @@ ls -lh "$OUT"/ echo "" echo "Install order:" echo " 1. meshbay-common" -echo " 2. meshbay-hub and/or meshbay-node" +echo " 2. meshbay-hub, meshbay-node and/or meshbay-cli" echo " 3. meshbay-client" diff --git a/packaging/conf/hub.toml.example b/packaging/conf/hub.toml.example index 850bee1..8e249a5 100644 --- a/packaging/conf/hub.toml.example +++ b/packaging/conf/hub.toml.example @@ -51,40 +51,15 @@ access_token_ttl = 14400 # 4 h # admin panel, not here. [captcha] -# reCAPTCHA v2 on registration and password reset, so no mail is ever sent -# before a human has been seen. Absent, or either key empty, disables it -# entirely — which is right for development and for a hub nobody can reach. -# See docs/MESHBAY_DESIGN.md §7.7. -site_key = "" -secret_key = "" +# A proof of work on registration and password reset, issued and checked by +# this hub alone: no third party, no cookie, nothing for a person to do — the +# page solves it while the form is filled in. On by default; turn it off only +# for development or a hub nobody can reach. See docs/MESHBAY_DESIGN.md §7.7. +enabled = true -# Hostnames a solved captcha may have been solved on, checked against the one -# `siteverify` reports — what Google observed, not what the client claims. -# -# Leave empty while the reCAPTCHA key does its own origin check: it is then -# already done, one layer up. Set it when you turn that check off in the -# reCAPTCHA console, and the two go together — turning the console check off -# without setting this leaves no origin check anywhere. -# -# The desktop client is why it exists. Its interface ships inside the package -# and is served from `app://meshbay`, so the hostname Google sees is not this -# hub's and never can be; with the console check on, the widget shows -# "Invalid domain for site key" and nothing client-side reaches that decision. -# -# allowed_hosts = ["hub.example.org", "localhost"] -allowed_hosts = [] - -# A solve Google cannot attribute to a domain reports an *empty* hostname — -# the desktop client's `app://` origin does, and so does any other non-web -# client. No `allowed_hosts` entry matches that, hence a flag rather than a -# blank list entry. -# -# What it admits is every non-web client, not only this project's: a file:// -# page or somebody else's Electron application look identical from here. The -# captcha still has to be solved per token; what is given up is the origin -# restriction for those clients. Leave it off unless you ship the desktop -# client. See docs/MESHBAY_DESIGN.md §7.7. -allow_unattributed_host = false +# The work asked of the page: half of this many SHA-256 on average, all of it +# at worst. The default takes about three seconds on a slow phone. +max_number = 500000 [mail] diff --git a/packaging/deb/meshbay-cli/DEBIAN/control b/packaging/deb/meshbay-cli/DEBIAN/control new file mode 100644 index 0000000..cb3753f --- /dev/null +++ b/packaging/deb/meshbay-cli/DEBIAN/control @@ -0,0 +1,18 @@ +Package: meshbay-cli +Version: __VERSION__ +Section: net +Priority: optional +Architecture: __ARCH__ +Maintainer: MeshBay Team <devel@meshbay.org> +Homepage: https://meshbay.org +Depends: meshbay-common (= __VERSION__) +Recommends: wl-clipboard | xclip +Description: MeshBay command-line client — a member's groups and files, from a terminal + meshbay-cli signs in to a MeshBay hub, lists the groups the account belongs + to and their folders, and downloads and uploads files, directly with the + node serving each group and end-to-end encrypted. With no argument it opens + an interactive two-pane view. + . + Installs into the shared venv at /opt/meshbay-common/venv/, with the Python + packages only the client needs (Textual, keyring, argon2-cffi). Administering + a node is meshbay-node's business, not this package's. diff --git a/packaging/deb/meshbay-cli/DEBIAN/postinst b/packaging/deb/meshbay-cli/DEBIAN/postinst new file mode 100755 index 0000000..e09d713 --- /dev/null +++ b/packaging/deb/meshbay-cli/DEBIAN/postinst @@ -0,0 +1,17 @@ +#!/bin/sh +set -e + +# The client runs as an unprivileged user, who cannot write bytecode into +# /opt: compiled here once, or every run compiles Textual again in memory. +case "$1" in + configure) + if command -v py3compile >/dev/null 2>&1; then + site=$(echo /opt/meshbay-common/venv/lib/python3.*/site-packages) + while IFS= read -r entry; do + [ -d "$site/$entry" ] && py3compile "$site/$entry" 2>/dev/null || true + done < /opt/meshbay-cli/site-packages.list + fi + ;; +esac + +#DEBHELPER# diff --git a/packaging/deb/meshbay-cli/DEBIAN/prerm b/packaging/deb/meshbay-cli/DEBIAN/prerm new file mode 100755 index 0000000..96e5930 --- /dev/null +++ b/packaging/deb/meshbay-cli/DEBIAN/prerm @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# The client's files live in meshbay-common's venv, whose own cleanup runs only +# after this package is gone: the bytecode compiled next to them is removed +# here, or dpkg cannot remove the directories it sits in. +case "$1" in + remove|upgrade|deconfigure) + site=$(echo /opt/meshbay-common/venv/lib/python3.*/site-packages) + while IFS= read -r entry; do + [ -n "$entry" ] && [ -d "$site/$entry" ] || continue + find "$site/$entry" -depth -type d -name __pycache__ \ + -exec rm -rf {} + 2>/dev/null || true + done < /opt/meshbay-cli/site-packages.list + ;; +esac + +#DEBHELPER# diff --git a/packaging/pip-audit-ignore.txt b/packaging/pip-audit-ignore.txt new file mode 100644 index 0000000..8aec984 --- /dev/null +++ b/packaging/pip-audit-ignore.txt @@ -0,0 +1,6 @@ +# Vulnerabilities packaging/audit_deps.py lets through, one per line: +# +# <id> # why it does not apply to MeshBay, and when to look again +# +# An id is what pip-audit prints (PYSEC-…, GHSA-…, CVE-…). Remove a line once +# the fixed version ships. diff --git a/packaging/rpm/meshbay-cli.spec b/packaging/rpm/meshbay-cli.spec new file mode 100644 index 0000000..3d387d8 --- /dev/null +++ b/packaging/rpm/meshbay-cli.spec @@ -0,0 +1,72 @@ +Name: meshbay-cli +Version: __VERSION__ +Release: 1%{?dist} +Summary: MeshBay command-line client — a member's groups and files, from a terminal +# The Python packages only the client needs go with it, each under its own +# licence: /opt/meshbay-cli/THIRD-PARTY-NOTICES.txt lists them. +License: AGPL-3.0-or-later +URL: https://meshbay.org + +AutoReqProv: no +# Not noarch: argon2-cffi's bindings are compiled code, as meshbay-common's are. + +Requires: meshbay-common = %{version} +# The interactive chat's right-click paste reads the clipboard with one of them. +Recommends: (wl-clipboard or xclip) + +%description +meshbay-cli signs in to a MeshBay hub, lists the groups the account belongs +to and their folders, and downloads and uploads files, directly with the node +serving each group and end-to-end encrypted. With no argument it opens an +interactive two-pane view. + +Installs into the shared venv at /opt/meshbay-common/venv/, with the Python +packages only the client needs (Textual, keyring, argon2-cffi). + +%install +cp -a %{_staging_root}/* %{buildroot}/ +install -Dm644 -t %{buildroot}%{_licensedir}/%{name} %{_repo_root}/LICENSE + +# After the whole transaction, so that meshbay-common's venv is in place. The +# client runs as an unprivileged user, who cannot write bytecode into /opt: +# compiled here once, or every run compiles Textual again in memory. +%posttrans +site=$(echo /opt/meshbay-common/venv/lib/python3.*/site-packages) +while IFS= read -r entry; do + [ -d "$site/$entry" ] || continue + /opt/meshbay-common/venv/bin/python -m compileall -q "$site/$entry" >/dev/null 2>&1 || true +done < /opt/meshbay-cli/site-packages.list + +# The client's files live in meshbay-common's venv: the bytecode compiled next +# to them is removed, or rpm cannot remove the directories it sits in. Before +# an upgrade too, while the list still names what the old version shipped. +%pre +if [ "$1" -ge 2 ] && [ -f /opt/meshbay-cli/site-packages.list ]; then + site=$(echo /opt/meshbay-common/venv/lib/python3.*/site-packages) + while IFS= read -r entry; do + [ -n "$entry" ] && [ -d "$site/$entry" ] || continue + find "$site/$entry" -depth -type d -name __pycache__ \ + -exec rm -rf {} + 2>/dev/null || true + done < /opt/meshbay-cli/site-packages.list +fi + +%preun +if [ "$1" -eq 0 ]; then + site=$(echo /opt/meshbay-common/venv/lib/python3.*/site-packages) + while IFS= read -r entry; do + [ -n "$entry" ] && [ -d "$site/$entry" ] || continue + find "$site/$entry" -depth -type d -name __pycache__ \ + -exec rm -rf {} + 2>/dev/null || true + done < /opt/meshbay-cli/site-packages.list +fi + +%files -f %{_staging_root}.files +%license %{_licensedir}/%{name} +/opt/meshbay-cli +/usr/bin/meshbay-cli +%{_mandir}/man1/meshbay-cli.1* +%{_datadir}/bash-completion/completions/meshbay-cli + +%changelog +* Wed Oct 14 2026 MeshBay Team <devel@meshbay.org> - %{version}-1 +- First package: the command-line client diff --git a/packaging/win/README.md b/packaging/win/README.md index 4ce1459..89185fe 100644 --- a/packaging/win/README.md +++ b/packaging/win/README.md @@ -11,13 +11,15 @@ Linux). `meshbay-common` rides along inside the node runtime. ├─ MeshBay.exe Electron client ├─ resources\ │ ├─ app.asar src/ + ui/ (the interface ships in the package) -│ ├─ firewall.ps1 adds/removes the four inbound rules (see below) +│ ├─ firewall.ps1 adds/removes the five inbound rules (see below) │ ├─ service.ps1 install/remove/status/run/end the boot-time task │ ├─ service-mode.ps1 elevated helper: service.ps1 + firewall.ps1 in one UAC prompt │ └─ node-runtime\ │ ├─ meshbay-node.exe frozen daemon and CLI (PyInstaller onedir) │ ├─ meshbay-nodew.exe the same daemon without a console (the Store │ │ package's startup task runs it) +│ ├─ meshbay-cli.exe the command-line client, a member's files from +│ │ a terminal (Full and Store, not Light) │ ├─ _internal\ … its Python + deps (aiortc, av, aioquic, …) │ ├─ ffmpeg.exe, ffprobe.exe bundled by default, see Video (ffmpeg) below │ ├─ av*.dll, swscale/swresample*.dll what those two link against @@ -33,8 +35,9 @@ as this same signed-in user (S4U, see below), so it is the same profile either way — not LocalSystem/NetworkService, which would have none of this. `build/installer.nsh` also adds `…\resources\node-runtime` to the **per-user** -`Path` (`HKCU\Environment`) so `meshbay-node` works in a terminal, and takes it -back out on uninstall. New shells only — a `WM_SETTINGCHANGE` broadcast nudges +`Path` (`HKCU\Environment`) so `meshbay-node` and `meshbay-cli` work in a +terminal, and takes it back out on uninstall. The Store package declares an +execution alias for each instead (`build/appx-extensions.xml`). New shells only — a `WM_SETTINGCHANGE` broadcast nudges open ones. It uses stock `WordFunc.nsh` (the `EnVar` plugin is not in electron-builder's NSIS bundle). @@ -151,7 +154,7 @@ That runs [`build-win.ps1`](build-win.ps1): | 2 | `npm ci` + download Electron's Chromium | | 3 | bump Electron to the latest release (Chromium CVE policy; `-NoElectronBump` to skip) | | 4 | `npm run sync-ui` — copy the interface from `meshbay-hub/.../static` | -| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/`, then [`smoke-node-runtime.ps1`](smoke-node-runtime.ps1) starts the frozen daemon in a throwaway profile and checks it answers with the right version and writes its log, and [`smoke-connection-process.py`](smoke-connection-process.py) starts both executables as connection processes (`connection_processes` above 1) and carries a connection through each | +| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/`, then [`smoke-node-runtime.ps1`](smoke-node-runtime.ps1) starts the frozen daemon in a throwaway profile and checks it answers with the right version and writes its log, and [`smoke-connection-process.py`](smoke-connection-process.py) starts both executables as connection processes (`connection_processes` above 1) and carries a connection through each; `meshbay-cli.exe` is run too — `--version`, `groups --help`, and its self-test (`MESHBAY_CLI_SELFTEST=1 meshbay-cli`), which loads what a frozen build can miss without failing until run: the interactive mode drawn off-screen, keyring's backends, the WebRTC stack | | 6 | `electron-builder --win nsis` → `packages/meshbay-client/dist/MeshBay-Setup-<version>.exe` | ### Video (ffmpeg) @@ -215,7 +218,7 @@ publish their host candidate as an unresolvable `<uuid>.local` mDNS name that `aioice` discards — the browser always dials the node, never the reverse. So the node has to accept unsolicited inbound UDP from its peers. -**Windows Defender Firewall.** The setup wizard offers to add four inbound +**Windows Defender Firewall.** The setup wizard offers to add five inbound rules in one step — it needs one admin confirmation (`build/installer.nsh` runs `firewall.ps1` via NSIS `ExecShellWait "runas"`; the per-user install itself never elevates): @@ -224,6 +227,7 @@ itself never elevates): |---|---|---|---| | `MeshBay` | `MeshBay.exe` | UDP, any port | WebRTC (ICE checks touch the client too) | | `MeshBay Node` | `meshbay-node.exe` | UDP, any port | WebRTC (the node — unsolicited inbound, see above) | +| `MeshBay CLI` | `meshbay-cli.exe` | UDP, any port | WebRTC (the command-line client during a transfer; ICE checks touch it as they do the app) | | `MeshBay Cast` | `MeshBay.exe` | TCP 19550–19553 | LAN cast HTTP relay (`src/cast-relay.js`) | | `MeshBay Cast Discovery` | `MeshBay.exe` | UDP 5353 | Chromecast/Smart TV mDNS discovery (`bonjour-service`) | @@ -284,6 +288,12 @@ FFmpeg DLLs), `aioquic`, `pydantic_core`, `uvicorn`, `watchdog`, `guessit`, add the package to `COLLECT_ALL` / `HIDDEN` / `COPY_META` in the spec — that is the expected way the list grows. +The command-line client has its own analysis in the same spec +(`CLI_COLLECT_ALL`, `CLI_COPY_META`): Textual loads its widgets lazily, Rich its +Unicode tables by version, keyring its backends through entry points, which +is why keyring's metadata is copied. The build's self-test run catches what +those lists miss. + ## Not done - **Authenticode signing** — Phase 13.9. Unsigned installer triggers SmartScreen. diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1 index c6a8af1..24baa0b 100644 --- a/packaging/win/build-node-runtime.ps1 +++ b/packaging/win/build-node-runtime.ps1 @@ -1,10 +1,12 @@ <# .SYNOPSIS - Freeze the MeshBay node daemon into a relocatable Windows binary. + Freeze the MeshBay node daemon, and the command-line client beside it, + into relocatable Windows binaries. .DESCRIPTION - Produces packages/meshbay-client/node-runtime/meshbay-node.exe (+ its - onedir payload) with PyInstaller. electron-builder then carries that tree + Produces packages/meshbay-client/node-runtime/meshbay-node.exe, + meshbay-nodew.exe and meshbay-cli.exe (+ their shared onedir payload) + with PyInstaller. electron-builder then carries that tree into the NSIS installer as an extraResource (see package.json build.win). The frozen binary is what the desktop client spawns and what the W3 @@ -76,11 +78,12 @@ if ($verMajor -lt 3 -or ($verMajor -eq 3 -and $verMinor -lt 12)) { Step "python $verMajor.$verMinor ($Python)" # --- 2. dependencies ----------------------------------------------------- -Step "installing meshbay-common, meshbay-node and PyInstaller" +Step "installing meshbay-common, meshbay-node, meshbay-cli and PyInstaller" & $Python -m pip install --upgrade pip --quiet & $Python -m pip install --quiet ` (Join-Path $Repo "packages\meshbay-common") ` (Join-Path $Repo "packages\meshbay-node") ` + (Join-Path $Repo "packages\meshbay-cli") ` "pyinstaller>=6.10" ` "tzdata" # tzdata: Windows ships no IANA zone database, so zoneinfo (pulled in @@ -88,6 +91,15 @@ Step "installing meshbay-common, meshbay-node and PyInstaller" # 'Hidden import "tzdata" not found' when it is absent. if ($LASTEXITCODE -ne 0) { throw "pip install failed" } +# Known vulnerabilities in what is about to be frozen: one stops the build +# unless packaging\pip-audit-ignore.txt says why it does not apply; no network +# only skips the audit, with a warning. pip-audit runs from a venv of its own, +# never from the one PyInstaller freezes. +Step "auditing dependencies (pip-audit)" +& $Python (Join-Path $Repo "packaging\audit_deps.py") ` + --tool-dir (Join-Path $Client "build\_pip-audit") +if ($LASTEXITCODE -ne 0) { throw "pip-audit found known vulnerabilities" } + # --- 3. freeze -------------------------------------------------------- if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir } $pyiWork = Join-Path $Client "build\_pyinstaller" @@ -105,7 +117,7 @@ finally { } $frozen = Join-Path $pyiDist "meshbay-node" -foreach ($name in "meshbay-node.exe", "meshbay-nodew.exe") { +foreach ($name in "meshbay-node.exe", "meshbay-nodew.exe", "meshbay-cli.exe") { if (-not (Test-Path (Join-Path $frozen $name))) { throw "PyInstaller did not produce $name at $frozen" } @@ -124,7 +136,7 @@ Copy-Item (Join-Path $Repo "packages\meshbay-common\COPYING.LESSER") (Join-Path Copy-Item (Join-Path $Repo "packages\meshbay-common\COPYING") (Join-Path $frozen "LICENSE-GPL-3.0.txt") & $Python (Join-Path $Repo "packaging\third_party_notices.py") ` -o (Join-Path $frozen "THIRD-PARTY-NOTICES.txt") ` - meshbay-node tzdata --extra pyinstaller --with-python + meshbay-node meshbay-cli tzdata --extra pyinstaller --with-python if ($LASTEXITCODE -ne 0) { throw "third_party_notices.py failed" } # --- 4. ffmpeg (bundled by default) ----------------------------------- @@ -137,8 +149,9 @@ else { } # --- 5. default.env (shared TMDB token) ------------------------------ -# Copied as is from QE\default.env (never versioned), one line: -# MESHBAY_TMDB_DEFAULT_TOKEN=eyJ... Beside the exe, where +# Taken from QE\default.env (never versioned). Only its first line ships, and +# only once it is exactly MESHBAY_TMDB_DEFAULT_TOKEN=<JWT>: anything else +# written in that file stays out of a public package. Beside the exe, where # platform.packaged_default_env() looks for it. The daemon reads it beneath # %LOCALAPPDATA%\meshbay\node.env and the operator's own token, so it is only # the fallback. No token, no build: an empty one goes unnoticed. The check is @@ -147,11 +160,13 @@ $defaultEnv = Join-Path $Repo "QE\default.env" $text = if (Test-Path -LiteralPath $defaultEnv) { [System.Text.Encoding]::ASCII.GetString([System.IO.File]::ReadAllBytes($defaultEnv)) } else { "" } -if (-not $text.StartsWith("MESHBAY_TMDB_DEFAULT_TOKEN=eyJ")) { - throw "$defaultEnv missing, or not starting with MESHBAY_TMDB_DEFAULT_TOKEN=eyJ... (no BOM)" +$tokenLine = ($text -split "`n", 2)[0].TrimEnd("`r") +if ($tokenLine -cnotmatch '^MESHBAY_TMDB_DEFAULT_TOKEN=eyJ[A-Za-z0-9_.-]+$') { + throw "$defaultEnv missing, or its first line is not MESHBAY_TMDB_DEFAULT_TOKEN=eyJ... (no BOM)" } -Copy-Item -LiteralPath $defaultEnv (Join-Path $frozen "default.env") -Step "default.env copied from QE\" +[System.IO.File]::WriteAllText((Join-Path $frozen "default.env"), "$tokenLine`n", + (New-Object System.Text.UTF8Encoding $false)) +Step "default.env: token line taken from QE\" # --- 6. publish ---------------------------------------------------- Move-Item $frozen $OutDir @@ -198,6 +213,29 @@ Step "smoke test: the frozen node as a connection process" & $Python (Join-Path $WinDir "smoke-connection-process.py") $exe (Join-Path $OutDir "meshbay-nodew.exe") if ($LASTEXITCODE -ne 0) { throw "a frozen connection process did not carry a connection" } +# The client is frozen from its own analysis, so what the daemon's checks +# proved says nothing about it: a module PyInstaller missed fails at run time +# only. --version and `groups --help` build the parser, which imports every +# action and the client library; the self-test loads what the parser does not +# -- the interactive mode drawn off-screen (Textual, Rich), keyring's backends +# (Windows Credential Manager), an aiortc offer. The CLI has the node's version +# (all packages share one). +Step "smoke test: meshbay-cli" +$cli = Join-Path $OutDir "meshbay-cli.exe" +$out = (& $cli --version 2>&1) -join "`n" +if ($LASTEXITCODE -ne 0 -or $out -notmatch "meshbay-cli $expect") { + throw "frozen meshbay-cli --version exited $LASTEXITCODE`n$out" +} +$out = (& $cli groups --help 2>&1) -join "`n" +if ($LASTEXITCODE -ne 0 -or $out -notmatch "List the groups") { + throw "frozen meshbay-cli groups --help exited $LASTEXITCODE`n$out" +} +$env:MESHBAY_CLI_SELFTEST = "1" +try { $out = (& $cli 2>&1) -join "`n" } finally { Remove-Item Env:MESHBAY_CLI_SELFTEST } +if ($LASTEXITCODE -ne 0 -or $out -notmatch "self-test ok") { + throw "frozen meshbay-cli self-test exited $LASTEXITCODE`n$out" +} + if ($createdVenv -and -not $KeepBuildVenv) { Remove-Item -Recurse -Force $BuildVenv -ErrorAction SilentlyContinue } diff --git a/packaging/win/cli-entry.py b/packaging/win/cli-entry.py new file mode 100644 index 0000000..908d4c7 --- /dev/null +++ b/packaging/win/cli-entry.py @@ -0,0 +1,14 @@ +"""PyInstaller entry point for the command-line client, `meshbay-cli.exe`. + +`meshbay_cli.__main__:main` is a module function; PyInstaller freezes a script. +Built beside meshbay-node.exe in the same runtime directory (meshbay-node.spec), +which the Full installer puts on the per-user PATH and the Store package names +in an execution alias. +""" + +import sys + +from meshbay_cli.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/win/firewall.ps1 b/packaging/win/firewall.ps1 index 939a58e..827daf5 100644 --- a/packaging/win/firewall.ps1 +++ b/packaging/win/firewall.ps1 @@ -6,7 +6,9 @@ Two independent things need an inbound allow, and Windows prompts "Allow access" for each the first time it happens if there is no rule: - 1. WebRTC (MeshBay.exe and meshbay-node.exe). It binds an ephemeral UDP + 1. WebRTC (MeshBay.exe, meshbay-node.exe and meshbay-cli.exe, the + command-line client, which carries files over WebRTC as the app does). + It binds an ephemeral UDP port per connection, and the browser always dials the node -- aioice cannot resolve the peer's mDNS `.local` candidate to call back -- so the node (and, during ICE connectivity checks, the client) must accept @@ -23,12 +25,12 @@ are fixed and known. Matches packaging/firewall/*/meshbay-cast.xml. The installer runs this once, elevated, so the user answers one UAC prompt - instead of up to four dialogs spread across first use of chat, downloads + instead of up to five dialogs spread across first use of chat, downloads and casting. Declining the installer's offer is fine -- the dialogs are the fallback, one per program/port combination as each is first used. Shipped as an extraResource at <install>\resources\firewall.ps1, so it - locates the two executables from its own path and takes no arguments + locates the executables from its own path and takes no arguments beyond the action. add/remove run elevated and windowless, leaving a trace at %TEMP%\meshbay-firewall.log; check is a plain read (no admin needed -- Get-NetFirewallRule does not require it, only New/Remove do) @@ -59,12 +61,14 @@ $GROUP = "MeshBay" $client = Join-Path $install "MeshBay.exe" $node = Join-Path $resources "node-runtime\meshbay-node.exe" +$cli = Join-Path $resources "node-runtime\meshbay-cli.exe" # Program-scoped, any port: the ephemeral-UDP-port problem WebRTC always has. # Port-scoped as well: the two fixed, known ports LAN casting actually uses. $rules = @( @{ Name = "MeshBay"; Path = $client; Protocol = "UDP"; LocalPort = "Any" } @{ Name = "MeshBay Node"; Path = $node; Protocol = "UDP"; LocalPort = "Any" } + @{ Name = "MeshBay CLI"; Path = $cli; Protocol = "UDP"; LocalPort = "Any" } @{ Name = "MeshBay Cast"; Path = $client; Protocol = "TCP"; LocalPort = "19550-19553" } @{ Name = "MeshBay Cast Discovery"; Path = $client; Protocol = "UDP"; LocalPort = "5353" } ) diff --git a/packaging/win/meshbay-node.spec b/packaging/win/meshbay-node.spec index a1173a2..8a6e384 100644 --- a/packaging/win/meshbay-node.spec +++ b/packaging/win/meshbay-node.spec @@ -1,6 +1,8 @@ -# PyInstaller spec for the bundled Windows node daemon. +# PyInstaller spec for the bundled Windows node daemon, and the command-line +# client beside it. # -# Output: dist/meshbay-node/meshbay-node.exe (--onedir, relocatable) +# Output: dist/meshbay-node/meshbay-node.exe, meshbay-nodew.exe and +# meshbay-cli.exe (--onedir, relocatable, one _internal\ for the three) # Consumed by: packaging/win/build-node-runtime.ps1, which moves the tree to # packages/meshbay-client/node-runtime/ for electron-builder to carry as an # extraResource. @@ -35,21 +37,27 @@ _vtuple = tuple( int(p) for p in (_ver.split("+")[0].split(".") + ["0", "0", "0", "0"])[:4] ) -_version_info = VSVersionInfo( - ffi=FixedFileInfo(filevers=_vtuple, prodvers=_vtuple), - kids=[ - StringFileInfo([StringTable("040904B0", [ - StringStruct("CompanyName", "MeshBay"), - StringStruct("FileDescription", "MeshBay Node"), - StringStruct("FileVersion", _ver), - StringStruct("InternalName", "meshbay-node"), - StringStruct("OriginalFilename", "meshbay-node.exe"), - StringStruct("ProductName", "MeshBay Node"), - StringStruct("ProductVersion", _ver), - ])]), - VarFileInfo([VarStruct("Translation", [0x0409, 1200])]), - ], -) + + +def _version_resource(name: str, description: str) -> VSVersionInfo: + return VSVersionInfo( + ffi=FixedFileInfo(filevers=_vtuple, prodvers=_vtuple), + kids=[ + StringFileInfo([StringTable("040904B0", [ + StringStruct("CompanyName", "MeshBay"), + StringStruct("FileDescription", description), + StringStruct("FileVersion", _ver), + StringStruct("InternalName", name), + StringStruct("OriginalFilename", f"{name}.exe"), + StringStruct("ProductName", description), + StringStruct("ProductVersion", _ver), + ])]), + VarFileInfo([VarStruct("Translation", [0x0409, 1200])]), + ], + ) + + +_version_info = _version_resource("meshbay-node", "MeshBay Node") # Packages taken in full: data files + shared libs + every submodule. COLLECT_ALL = [ @@ -177,11 +185,96 @@ exe_windowless = EXE( icon="../../packages/meshbay-client/build/icon.ico", version=_version_info, ) + +# --- The command-line client --------------------------------------------------- +# Its own analysis: another entry point, and packages the daemon does not use +# (Textual loads its widgets lazily, Rich its Unicode tables by version, +# Pygments its lexers by name, keyring its backends through entry points — +# hence keyring's metadata). What both import (aiortc, av, cryptography, ...) +# lands once in _internal\: COLLECT keeps one copy of a file the two share. +CLI_COLLECT_ALL = [ + "meshbay_cli", + "meshbay_common", + "aiortc", + "aioice", + "av", + "cryptography", + "pylibsrtp", + "pyee", + "google_crc32c", + "httpx", + "httpcore", + "h11", + "anyio", + "dns", + "argon2", + "_argon2_cffi_bindings", + "keyring", + "win32ctypes", + "textual", + "rich", + "pygments", + "markdown_it", + "mdit_py_plugins", + "linkify_it", + "platformdirs", + "blake3", + "msgpack", + "jwt", + "tzdata", +] +CLI_COPY_META = ["meshbay-cli", "meshbay-common", "keyring", "textual"] + +cli_datas, cli_binaries, cli_hidden = [], [], [] +for pkg in CLI_COLLECT_ALL: + d, b, h = collect_all(pkg) + cli_datas += d + cli_binaries += b + cli_hidden += h +for dist in CLI_COPY_META: + cli_datas += copy_metadata(dist) + +cli_a = Analysis( + ["cli-entry.py"], + pathex=[], + binaries=cli_binaries, + datas=cli_datas, + hiddenimports=cli_hidden, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tkinter", "pytest", "ruff", "IPython", "meshbay_node", "meshbay_hub"], + noarchive=False, +) +cli_pyz = PYZ(cli_a.pure) +exe_cli = EXE( + cli_pyz, + cli_a.scripts, + [], + exclude_binaries=True, + name="meshbay-cli", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon="../../packages/meshbay-client/build/icon.ico", + version=_version_resource("meshbay-cli", "MeshBay command-line client"), +) + coll = COLLECT( exe, exe_windowless, + exe_cli, a.binaries, a.datas, + cli_a.binaries, + cli_a.datas, strip=False, upx=False, upx_exclude=[], |