aboutsummaryrefslogtreecommitdiffstats
path: root/packaging
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit422647e7898b8df67cd11f8fb060aec23dec84aa (patch)
tree7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packaging
parent28c88908b88c58cba4de08710f6bb268ede02d57 (diff)
downloadmeshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz
feat(cli): meshbay-cli, a command-line client for members
Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packaging')
-rw-r--r--packaging/README.md38
-rwxr-xr-xpackaging/build/build-cli.sh82
-rwxr-xr-xpackaging/build/build-common.sh19
-rwxr-xr-xpackaging/build/build-packages.sh16
-rw-r--r--packaging/deb/meshbay-cli/DEBIAN/control17
-rwxr-xr-xpackaging/deb/meshbay-cli/DEBIAN/postinst17
-rwxr-xr-xpackaging/deb/meshbay-cli/DEBIAN/prerm18
-rw-r--r--packaging/rpm/meshbay-cli.spec69
-rw-r--r--packaging/win/README.md20
-rw-r--r--packaging/win/build-node-runtime.ps138
-rw-r--r--packaging/win/cli-entry.py14
-rw-r--r--packaging/win/firewall.ps110
-rw-r--r--packaging/win/meshbay-node.spec127
13 files changed, 440 insertions, 45 deletions
diff --git a/packaging/README.md b/packaging/README.md
index 6081fc7..55cc39a 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,33 @@ 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`.
+
+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/build/build-cli.sh b/packaging/build/build-cli.sh
new file mode 100755
index 0000000..b697f10
--- /dev/null
+++ b/packaging/build/build-cli.sh
@@ -0,0 +1,82 @@
+#!/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; }
+
+# --- 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..2010d1b 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,6 +44,17 @@ 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" \
@@ -64,7 +75,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-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/deb/meshbay-cli/DEBIAN/control b/packaging/deb/meshbay-cli/DEBIAN/control
new file mode 100644
index 0000000..6c64c09
--- /dev/null
+++ b/packaging/deb/meshbay-cli/DEBIAN/control
@@ -0,0 +1,17 @@
+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__)
+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/rpm/meshbay-cli.spec b/packaging/rpm/meshbay-cli.spec
new file mode 100644
index 0000000..5d9aeb6
--- /dev/null
+++ b/packaging/rpm/meshbay-cli.spec
@@ -0,0 +1,69 @@
+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}
+
+%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*
+
+%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..28b96e8 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
@@ -105,7 +108,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 +127,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) -----------------------------------
@@ -198,6 +201,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=[],