R2D2-MERIDIAN/run.sh
Joshua Belke 6606e618dd fix(bus): bind the zenohd router to loopback, as its commit title already claimed
The council's C11 live re-probe injected a forged, invalid-signature event
into the bus FROM A LAN ADDRESS and a real WebSocket client received it as
authentic. The router was reachable because the "loopback-only" profile
published it with a bare "${ZENOH_HOST_PORT:-7447}:7447", which Docker
binds to 0.0.0.0.

The NonLocalEndpoint refusal that this campaign treated as the boundary
confines the relay's DIAL address. It cannot confine the router's LISTEN
address, and nothing else did. So the guard's stated threat model --
"keep the endpoint on loopback", in its own error string -- was not
achieved by the shipped profile, and the commit title said loopback while
the port said every interface.

Fixed in both places, because either alone is useless: docker-compose.yml
publishes on 127.0.0.1, and run.sh's generated override does too. The
generator matters more -- its block is `!override`, so it REPLACES the
base ports list, and a bare mapping there would have silently undone the
base fix on every machine run.sh touches.

Found by live probe, not by review or by any test. The 21 zenoh_bus tests
pass against the vulnerable configuration, because none of them stands up
a router on a routable interface.

Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
2026-08-20 12:18:25 -04:00

1844 lines
79 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# run.sh — one-command local launcher for the Meridian stack.
# =============================================================================
# Builds and runs the relay, the Tauri 2 desktop app, the web client, and the
# admin dashboard, on host ports that are probed for availability first.
#
# This script does not reimplement the build. Every build and run step is a
# `just` recipe; run.sh resolves a conflict-free set of ports, writes them to
# the two override layers the repo already documents, and then drives `just`
# with that environment. There is no third configuration mechanism:
#
# .env.local relay + scripts (a managed block, appended)
# docker-compose.override.yml Compose host ports (generated, gitignored)
#
# Both are gitignored. `just compose-check` asserts the two agree on every host
# port, and the writers below keep the paired values (host port <-> connection
# URL) in lockstep, which is the single most time-wasting failure in this stack.
#
# Written for bash 3.2 — the version macOS ships — so no associative arrays.
#
# Start here: ./run.sh doctor diagnose this host
# ./run.sh all bring everything up
# =============================================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$REPO_ROOT"
# Hermit pins the toolchain (just, cargo, node, pnpm). Prepending is what every
# Justfile recipe does; it avoids activate-hermit's interactive-shell
# assumptions while resolving the same binaries.
export PATH="$REPO_ROOT/bin:$PATH"
STATE_DIR="$REPO_ROOT/.meridian-run"
BACKUP_DIR="$STATE_DIR/backups"
LOG_DIR="$STATE_DIR/logs"
PID_DIR="$STATE_DIR/pids"
PROFILE_DIR="$REPO_ROOT/.settings/run-profiles"
ENV_LOCAL="$REPO_ROOT/.env.local"
COMPOSE_OVERRIDE="$REPO_ROOT/docker-compose.override.yml"
BLOCK_BEGIN="# >>> meridian run.sh managed block >>>"
BLOCK_END="# <<< meridian run.sh managed block <<<"
# Logical port name -> environment variable it is published as.
PORT_KEYS="postgres redis minio minio_console adminer keycloak prometheus zenoh relay health metrics control_plane control_plane_health desktop_vite desktop_hmr web"
# ── Options ──────────────────────────────────────────────────────────────────
PROFILE="${MERIDIAN_RUN_PROFILE:-local}"
FRESH_PORTS=0
NO_BUILD=0
RELEASE=0
VERBOSE=0
ASSUME_YES=0
FULL=0
ATTACH=0
MESH=0
# ── Output helpers ───────────────────────────────────────────────────────────
if [ -t 1 ]; then
C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m'
else
C_RESET=""; C_DIM=""; C_BOLD=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE=""
fi
info() { printf '%s==>%s %s\n' "$C_BLUE" "$C_RESET" "$*"; }
ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
err() { printf '%sfail%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
die() { err "$*"; exit 1; }
dbg() { [ "$VERBOSE" -eq 1 ] && printf '%s %s%s\n' "$C_DIM" "$*" "$C_RESET" >&2 || true; }
have() { command -v "$1" >/dev/null 2>&1; }
# bash 3.2 has no associative arrays; name-mangled variables plus ${!ref}
# indirection give the same lookup without requiring bash 4.
setvar() { eval "$1=\"\$2\""; }
getvar() { eval "printf '%s' \"\${$1:-}\""; }
upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]'; }
pref_of() { getvar "PREF_$(upper "$1")"; }
port_of() { getvar "PORT_$(upper "$1")"; }
# =============================================================================
# Help
# =============================================================================
usage() {
cat <<'EOF'
run.sh — one-command local launcher for the Meridian stack
USAGE
./run.sh <command> [options]
COMMANDS
Environment
doctor Diagnose this host: toolchain, Docker, port conflicts,
stale processes, and drift between .env and the overrides.
ports Resolve and print the host ports for this checkout.
env Print the resolved environment as shell exports
(use: eval "$(./run.sh env)").
config Show the active profile and the layers in effect.
Services
up Start the Docker backing stack (postgres, dragonfly, minio,
plus any profile extras) on deconflicted ports.
down Stop the backing stack, keeping volumes.
status Show container health plus live probes of every component.
logs [service] Tail Docker service logs.
migrate Apply database migrations and seed the local community.
Components
build Build the Rust workspace and the desktop sidecar binaries.
bundle Build a distributable Meridian.app with real sidecar
binaries. Output:
REMAPPING/meridian-desktop/src-tauri/target/release/bundle/macos/
--full also produces a .dmg; --mesh adds shared compute.
relay Run the relay in the foreground (starts services first).
control-plane Run the relay control plane in the foreground. The
desktop app cannot create relays without it.
desktop Run the Tauri 2 desktop app. Starts its own relay unless
--attach is given.
web Run the web client dev server.
admin Build admin-web and serve it from the relay.
all Services + relay + web in the background, then the desktop
app in the foreground. Ctrl-C stops everything.
stop Stop background processes that run.sh started.
Verification
qa Format, lint, typecheck, and unit-test gates.
--full runs the complete `just ci` gate.
e2e End-to-end probes against a running stack: Postgres,
Dragonfly, MinIO, relay health, NIP-11, and the Nostr
HTTP bridge. --full also runs the Playwright smoke suites.
Maintenance
reset Wipe local dev state and recreate a clean environment.
clean-legacy Find and offer to stop pre-rebrand containers and relay
processes that are squatting on this stack's ports.
OPTIONS
--profile NAME Profile from .settings/run-profiles (default: local).
--fresh-ports Discard the saved allocation and probe for ports again.
--no-build Skip cargo/pnpm builds; run what is already compiled.
--release Build and run the relay in release mode.
--attach For `desktop`: connect to an already-running relay.
--full For `qa`/`e2e`: run the complete suite.
--mesh For `desktop`/`all`/`bundle`: compile in shared compute
(the `mesh-llm` feature). Off by default — it adds ~420
crates and a llama.cpp runtime build. Without it,
Settings > Compute reads "Not included in this build".
-y, --yes Do not prompt; assume yes.
-v, --verbose Print extra detail.
-h, --help Show this help.
EXAMPLES
./run.sh doctor # first thing to run on a new machine
./run.sh all # full stack, one command
./run.sh relay # just the relay, foreground
./run.sh desktop --attach # desktop against a relay already running
./run.sh e2e # prove the running stack actually works
./run.sh ports --fresh-ports # re-deconflict after another stack moved in
./run.sh bundle --mesh # .app that can actually share compute
PORTS
Every port in the profile is a starting point. run.sh probes it and walks
upward until it finds one nothing on this host is listening on — including
ports published by a second container runtime. The result is saved to
.env.local so rebuilds stay cache-warm, and reused until --fresh-ports.
EOF
}
# =============================================================================
# Profile loading
# =============================================================================
PROFILE_FILE=""
COMPOSE_PROJECT=""
COMPOSE_PROFILE_LIST=""
DB_USER=""; DB_PASS=""; DB_NAME=""
SEARCH_LIMIT=400
RESERVED_PORTS=""
load_profile() {
PROFILE_FILE="$PROFILE_DIR/$PROFILE.yaml"
[ -f "$PROFILE_FILE" ] || die "no such profile: $PROFILE (looked for ${PROFILE_FILE#$REPO_ROOT/})"
local parsed line key value
parsed="$(python3 - "$PROFILE_FILE" "$PORT_KEYS" <<'PY'
import sys
path = sys.argv[1]
try:
import yaml
data = yaml.safe_load(open(path)) or {}
except ImportError:
# Minimal fallback for the flat subset this profile format uses: two-level
# mappings and simple "- item" sequences. Keeps run.sh working on a host
# whose python3 has no PyYAML.
data = {}
cur, seq = data, None
for raw in open(path):
line = raw.split("#", 1)[0].rstrip()
if not line.strip():
continue
indent = len(line) - len(line.lstrip())
body = line.strip()
if body.startswith("- "):
if seq is not None:
seq.append(body[2:].strip().strip("\"'"))
continue
seq = None
key, _, val = body.partition(":")
key, val = key.strip(), val.strip().strip("\"'")
if indent == 0:
if val:
data[key] = val
else:
data[key] = {}
cur = data[key]
else:
if val:
cur[key] = val
else:
cur[key] = []
seq = cur[key]
def section(name):
value = data.get(name)
return value if isinstance(value, dict) else {}
compose = section("compose")
profiles = compose.get("profiles") or []
if isinstance(profiles, str):
profiles = [p.strip() for p in profiles.split(",") if p.strip()]
db = section("database")
ports = section("ports")
alloc = section("allocation")
reserved = alloc.get("reserved") or []
if isinstance(reserved, str):
reserved = [reserved]
print("COMPOSE_PROJECT=%s" % (compose.get("project") or "meridian"))
print("COMPOSE_PROFILE_LIST=%s" % ",".join(str(p) for p in profiles))
print("DB_USER=%s" % (db.get("user") or "meridian"))
print("DB_PASS=%s" % (db.get("password") or "meridian_dev"))
print("DB_NAME=%s" % (db.get("name") or "meridian"))
print("SEARCH_LIMIT=%s" % (alloc.get("search_limit") or 400))
print("RESERVED_PORTS=%s" % ",".join(str(r) for r in reserved))
# Driven by PORT_KEYS rather than a list of its own. A second copy of these
# names silently drops any key added to only one of them: the port never gets
# its profile preference, falls back to the 20000 scratch range, and looks
# deliberate in the output.
for key in sys.argv[2].split():
print("PREF_%s=%s" % (key.upper(), ports.get(key, 0)))
PY
)" || die "could not parse profile ${PROFILE_FILE#$REPO_ROOT/}"
while IFS= read -r line; do
[ -z "$line" ] && continue
key="${line%%=*}"; value="${line#*=}"
case "$key" in
PREF_*) setvar "$key" "$value" ;;
COMPOSE_PROJECT) COMPOSE_PROJECT="$value" ;;
COMPOSE_PROFILE_LIST) COMPOSE_PROFILE_LIST="$value" ;;
DB_USER) DB_USER="$value" ;;
DB_PASS) DB_PASS="$value" ;;
DB_NAME) DB_NAME="$value" ;;
SEARCH_LIMIT) SEARCH_LIMIT="$value" ;;
RESERVED_PORTS) RESERVED_PORTS="$value" ;;
esac
done <<EOF
$parsed
EOF
dbg "profile=$PROFILE project=$COMPOSE_PROJECT profiles=${COMPOSE_PROFILE_LIST:-<none>}"
}
# =============================================================================
# Port allocation
# =============================================================================
var_for_port() {
case "$1" in
postgres) printf 'MERIDIAN_PG_HOST_PORT' ;;
redis) printf 'REDIS_HOST_PORT' ;;
minio) printf 'MERIDIAN_MINIO_PORT' ;;
minio_console) printf 'MERIDIAN_MINIO_CONSOLE_PORT' ;;
adminer) printf 'MERIDIAN_ADMINER_PORT' ;;
keycloak) printf 'MERIDIAN_KEYCLOAK_PORT' ;;
prometheus) printf 'MERIDIAN_PROMETHEUS_PORT' ;;
zenoh) printf 'ZENOH_HOST_PORT' ;;
relay) printf 'MERIDIAN_RELAY_PORT' ;;
health) printf 'MERIDIAN_HEALTH_PORT' ;;
metrics) printf 'MERIDIAN_METRICS_PORT' ;;
control_plane) printf 'MERIDIAN_CONTROL_PORT' ;;
control_plane_health) printf 'MERIDIAN_CONTROL_HEALTH_PORT' ;;
desktop_vite) printf 'MERIDIAN_VITE_PORT' ;;
desktop_hmr) printf 'MERIDIAN_HMR_PORT' ;;
web) printf 'MERIDIAN_WEB_PORT' ;;
esac
}
# Host ports currently published by THIS compose project. A port we already own
# is not a conflict — reusing it keeps the stack in place across runs.
our_published_ports() {
have docker || return 0
docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" \
--format '{{.Ports}}' 2>/dev/null |
tr ',' '\n' | sed -n 's/.*:\([0-9][0-9]*\)->.*/\1/p' |
sort -u | tr '\n' ',' || true
}
# The previously saved allocation, read back out of the managed block.
saved_ports() {
[ -f "$ENV_LOCAL" ] || return 0
awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '
$0 == b { inblock = 1; next }
$0 == e { inblock = 0; next }
inblock { print }
' "$ENV_LOCAL"
}
saved_port_for() {
saved_ports | awk -F= -v k="$1" '$1 == k { print $2 }' | tail -1
}
# The worktree-derived base that scripts/instance-env.sh uses, so a profile
# value of 0 lands on the port `just dev` would otherwise have picked.
worktree_base_port() {
python3 -c "import hashlib,sys; h=int(hashlib.sha256(sys.argv[1].encode()).hexdigest(),16); print(10000 + h % 55000)" "$REPO_ROOT"
}
resolve_ports() {
local base ours prefer_list key pref saved resolved line
base="$(worktree_base_port)"
ours="$(our_published_ports)"
prefer_list=""
for key in $PORT_KEYS; do
pref="$(pref_of "$key")"
[ -z "$pref" ] && pref=0
if [ "$pref" = "0" ]; then
case "$key" in
desktop_vite) pref="$base" ;;
desktop_hmr) pref=$((base + 1)) ;;
web) pref=$((base + 100)) ;;
*) pref=0 ;;
esac
fi
saved=""
if [ "$FRESH_PORTS" -eq 0 ]; then
saved="$(saved_port_for "$(var_for_port "$key")")"
fi
prefer_list="${prefer_list}${key}:${pref}:${saved}
"
done
resolved="$(python3 - "$SEARCH_LIMIT" "$RESERVED_PORTS" "$ours" "$prefer_list" <<'PY'
import socket, sys
limit = int(sys.argv[1])
reserved = set(int(p) for p in sys.argv[2].split(",") if p.strip().isdigit())
ours = set(int(p) for p in sys.argv[3].split(",") if p.strip().isdigit())
requests = sys.argv[4].splitlines()
def free(port):
"""A port is free only if nothing on this host answers for it.
Binding is the honest test: it catches listeners from every container
runtime at once, which scanning one runtime's `docker ps` does not. Both
the wildcard and the loopback address are probed because Compose may
publish to either, and a partial bind still collides at container start.
"""
if port in reserved:
return False
if port in ours:
return True # already published by this stack; reusing it is correct
for addr in ("0.0.0.0", "127.0.0.1"):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((addr, port))
except OSError:
return False
finally:
s.close()
return True
taken = set()
out = []
for line in requests:
if not line.strip():
continue
parts = (line.split(":", 2) + ["", ""])[:3]
key, pref, saved = parts[0], parts[1], parts[2]
pref = int(pref or 0)
saved_port = int(saved) if saved.strip().isdigit() else None
chosen = None
if saved_port is not None and saved_port not in taken:
# A saved allocation is STICKY, and deliberately not re-probed. It was
# verified free when it was chosen, and by the time run.sh is invoked
# again the stack itself is usually listening on it — so a liveness
# test here would read our own relay as a conflict and walk the port
# out from under the running process, breaking `status`, `e2e`, and
# `desktop --attach`. --fresh-ports is how you ask for a re-probe.
chosen = saved_port
if chosen is None:
start = pref if pref else 20000
for offset in range(limit):
cand = start + offset
if cand > 65535:
break
if cand not in taken and free(cand):
chosen = cand
break
if chosen is None:
sys.exit("no free port for %s starting at %s" % (key, pref))
taken.add(chosen)
out.append("%s=%s" % (key, chosen))
print("\n".join(out))
PY
)" || die "port allocation failed"
while IFS= read -r line; do
[ -z "$line" ] && continue
setvar "PORT_$(upper "${line%%=*}")" "${line#*=}"
done <<EOF
$resolved
EOF
}
# =============================================================================
# Derived environment
# =============================================================================
export_env() {
export MERIDIAN_RUN_PROFILE="$PROFILE"
export MERIDIAN_PG_HOST_PORT="$PORT_POSTGRES"
export REDIS_HOST_PORT="$PORT_REDIS"
export MERIDIAN_MINIO_PORT="$PORT_MINIO"
export MERIDIAN_MINIO_CONSOLE_PORT="$PORT_MINIO_CONSOLE"
export MERIDIAN_ADMINER_PORT="$PORT_ADMINER"
export MERIDIAN_KEYCLOAK_PORT="$PORT_KEYCLOAK"
export MERIDIAN_PROMETHEUS_PORT="$PORT_PROMETHEUS"
export MERIDIAN_RELAY_PORT="$PORT_RELAY"
export MERIDIAN_HEALTH_PORT="$PORT_HEALTH"
export MERIDIAN_METRICS_PORT="$PORT_METRICS"
export MERIDIAN_BIND_ADDR="0.0.0.0:$PORT_RELAY"
export MERIDIAN_RELAY_URL="ws://localhost:$PORT_RELAY"
export MERIDIAN_ADMIN_HOST="admin.localhost:$PORT_RELAY"
# Control plane. Six values keyed off two ports, and every one of them has
# to move in step:
# * the desktop pair (URL + ORIGIN) is what `is_control_plane_configured`
# checks — with either half missing the app fails closed and reports
# "no community control plane configured" instead of offering to create
# one. The URL carries the API base path; the origin must not.
# * PUBLIC_ORIGIN is what the control plane enforces the identity-binding
# Origin check against, so a mismatch with the desktop's ORIGIN fails
# mid-flow with `invalid_origin` rather than up front.
# * its database lives on the relocated Postgres, in its own database —
# sharing the relay's would collide the two migration histories.
# * COMMUNITY_HOST_PORT is the relay's port, not the control plane's:
# it is baked into the `<name>.relays.meridian.localtest.me:PORT` URLs
# handed back to clients.
export MERIDIAN_CONTROL_PORT="$PORT_CONTROL_PLANE"
export MERIDIAN_CONTROL_HEALTH_PORT="$PORT_CONTROL_PLANE_HEALTH"
export MERIDIAN_CONTROL_BIND_ADDR="0.0.0.0:$PORT_CONTROL_PLANE"
export MERIDIAN_CONTROL_HEALTH_ADDR="0.0.0.0:$PORT_CONTROL_PLANE_HEALTH"
export MERIDIAN_CONTROL_PUBLIC_ORIGIN="http://127.0.0.1:$PORT_CONTROL_PLANE"
export MERIDIAN_CONTROL_API_BASE_PATH="${MERIDIAN_CONTROL_API_BASE_PATH:-/api/meridian}"
export MERIDIAN_CONTROL_DATABASE_URL="postgres://${DB_USER}:${DB_PASS}@localhost:${PORT_POSTGRES}/${DB_NAME}_control_plane"
export MERIDIAN_CONTROL_RELAY_OPERATOR_API_ORIGIN="http://127.0.0.1:$PORT_RELAY"
export MERIDIAN_CONTROL_COMMUNITY_HOST_PORT="$PORT_RELAY"
# The relay half of the operator handshake. The relay rebuilds the signed
# string from its own copy, so this must match byte-for-byte.
export RELAY_OPERATOR_API_ORIGIN="http://127.0.0.1:$PORT_RELAY"
# Read by the desktop app (runtime env beats the compile-time bake).
export MERIDIAN_CONTROL_PLANE_URL="http://127.0.0.1:${PORT_CONTROL_PLANE}${MERIDIAN_CONTROL_API_BASE_PATH}"
export MERIDIAN_CONTROL_PLANE_ORIGIN="http://127.0.0.1:$PORT_CONTROL_PLANE"
# Display-only. MERIDIAN_CONTROL_PLANE_URL above is an API BASE PATH that
# clients append /v1/... to — it is not a route, and fetching it returns 404.
# Printing it as though it were a link sends anyone checking the stack to a
# dead page and makes a working control plane look broken. These two are the
# addresses a human can actually open.
CONTROL_UI_URL="http://127.0.0.1:${PORT_CONTROL_PLANE}${MERIDIAN_CONTROL_API_BASE_PATH}/relays"
CONTROL_HEALTH_URL="http://127.0.0.1:${PORT_CONTROL_PLANE_HEALTH}/health/live"
# Paired with the published host ports above. These three pairs drifting
# apart is the failure this script exists to prevent.
export DATABASE_URL="postgres://${DB_USER}:${DB_PASS}@localhost:${PORT_POSTGRES}/${DB_NAME}"
export REDIS_URL="redis://localhost:${PORT_REDIS}"
export MERIDIAN_S3_ENDPOINT="http://localhost:${PORT_MINIO}"
# libpq variables, for the psql-based scripts (seed-local-community.sh and
# the operator SQL under scripts/). Left at their defaults these resolve to
# localhost:5432, which on this host is another project's database: the
# script then reports success against the wrong server while the relay's own
# tables stay empty.
export PGHOST=localhost
export PGPORT="$PORT_POSTGRES"
export PGUSER="$DB_USER"
export PGPASSWORD="$DB_PASS"
export PGDATABASE="$DB_NAME"
# seed-local-community.sh derives the community host allowlist from this,
# not from MERIDIAN_RELAY_URL. The relay fails closed on a Host header it has
# no row for, so a stale value here means every request 404s.
export RELAY_URL="ws://localhost:${PORT_RELAY}"
export MERIDIAN_VITE_PORT="$PORT_DESKTOP_VITE"
export MERIDIAN_HMR_PORT="$PORT_DESKTOP_HMR"
export MERIDIAN_WEB_PORT="$PORT_WEB"
export VITE_PORT="$PORT_DESKTOP_VITE"
export VITE_HMR_PORT="$PORT_DESKTOP_HMR"
export COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT"
[ -n "$COMPOSE_PROFILE_LIST" ] && export COMPOSE_PROFILES="$COMPOSE_PROFILE_LIST"
# Compose reads .env.local only when this comes from the shell; setting it
# inside .env is silently ignored.
export COMPOSE_ENV_FILES=".env,.env.local"
return 0
}
# =============================================================================
# Override-layer writers
# =============================================================================
backup_once() {
local file="$1" stamp dest
[ -f "$file" ] || return 0
mkdir -p "$BACKUP_DIR"
stamp="$(date +%Y%m%d-%H%M%S)"
dest="$BACKUP_DIR/$(basename "$file").$stamp"
cp "$file" "$dest"
dbg "backed up $(basename "$file") -> ${dest#$REPO_ROOT/}"
}
write_env_local() {
backup_once "$ENV_LOCAL"
local preserved=""
if [ -f "$ENV_LOCAL" ]; then
# Keep every hand-written line; replace only our own block.
preserved="$(awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '
$0 == b { inblock = 1; next }
$0 == e { inblock = 0; next }
!inblock { print }
' "$ENV_LOCAL")"
fi
{
if [ -n "$preserved" ]; then
printf '%s\n' "$preserved"
else
cat <<'EOF'
# =============================================================================
# .env.local — machine-specific values for the relay and scripts (gitignored)
# =============================================================================
# Compose reads this file only via COMPOSE_ENV_FILES, which must come from the
# shell. `just` exports it; a bare `docker compose up -d` does not.
EOF
fi
printf '\n%s\n' "$BLOCK_BEGIN"
cat <<EOF
# Generated by ./run.sh --profile ${PROFILE}. Edit the profile, not this block:
# .settings/run-profiles/${PROFILE}.yaml
# Re-probe for free ports with: ./run.sh ports --fresh-ports
#
# Appended last on purpose. Compose and the \`set -a; . ./.env.local\` in the
# Justfile both take the LAST assignment of a key, so these values win over
# anything above — including ports left behind by an earlier stack.
# --- Compose-published host ports (mirrored in docker-compose.override.yml) ---
MERIDIAN_PG_HOST_PORT=${PORT_POSTGRES}
REDIS_HOST_PORT=${PORT_REDIS}
MERIDIAN_MINIO_PORT=${PORT_MINIO}
MERIDIAN_MINIO_CONSOLE_PORT=${PORT_MINIO_CONSOLE}
MERIDIAN_ADMINER_PORT=${PORT_ADMINER}
MERIDIAN_KEYCLOAK_PORT=${PORT_KEYCLOAK}
MERIDIAN_PROMETHEUS_PORT=${PORT_PROMETHEUS}
# \`bus\` profile only. Allocated whether or not the profile is on, because a
# port that is only resolved when a profile happens to be enabled is a port
# that moves under you the first time someone enables it.
ZENOH_HOST_PORT=${PORT_ZENOH}
# --- Connection strings, paired with the ports above -------------------------
# Moving a port without its URL yields "Connection refused" from the relay with
# every container healthy. run.sh always writes the pair together.
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@localhost:${PORT_POSTGRES}/${DB_NAME}
REDIS_URL=redis://localhost:${PORT_REDIS}
MERIDIAN_S3_ENDPOINT=http://localhost:${PORT_MINIO}
# The bus has no discovery fallback — multicast and gossip scouting are both
# off in the router config — so this endpoint list is the ONLY way a relay
# finds the router. It is written unconditionally for the same reason
# REDIS_URL is: the pair must never be half-applied.
MERIDIAN_ZENOH_ENDPOINTS=tcp/localhost:${PORT_ZENOH}
# libpq variables for the psql-based scripts. Defaults resolve to localhost:5432,
# which on a busy host is another project's database — the seed then reports
# success against the wrong server and the relay 404s on every request.
PGHOST=localhost
PGPORT=${PORT_POSTGRES}
PGUSER=${DB_USER}
PGPASSWORD=${DB_PASS}
PGDATABASE=${DB_NAME}
# seed-local-community.sh derives the community host allowlist from RELAY_URL,
# not MERIDIAN_RELAY_URL. The relay fails closed on an unknown Host header.
RELAY_URL=ws://localhost:${PORT_RELAY}
# --- Relay host process ------------------------------------------------------
MERIDIAN_RELAY_PORT=${PORT_RELAY}
MERIDIAN_BIND_ADDR=0.0.0.0:${PORT_RELAY}
MERIDIAN_HEALTH_PORT=${PORT_HEALTH}
MERIDIAN_METRICS_PORT=${PORT_METRICS}
MERIDIAN_RELAY_URL=ws://localhost:${PORT_RELAY}
MERIDIAN_ADMIN_HOST=admin.localhost:${PORT_RELAY}
# --- Control plane host process ----------------------------------------------
# Community provisioning. The desktop pair below is what the app checks before
# offering to create a community; with either half missing it fails closed with
# "no community control plane configured". PUBLIC_ORIGIN must equal
# MERIDIAN_CONTROL_PLANE_ORIGIN or identity binding fails with invalid_origin.
# The control plane keeps its own database — sharing the relay's would collide
# two migration histories.
MERIDIAN_CONTROL_PORT=${PORT_CONTROL_PLANE}
MERIDIAN_CONTROL_HEALTH_PORT=${PORT_CONTROL_PLANE_HEALTH}
MERIDIAN_CONTROL_BIND_ADDR=0.0.0.0:${PORT_CONTROL_PLANE}
MERIDIAN_CONTROL_HEALTH_ADDR=0.0.0.0:${PORT_CONTROL_PLANE_HEALTH}
MERIDIAN_CONTROL_PUBLIC_ORIGIN=http://127.0.0.1:${PORT_CONTROL_PLANE}
MERIDIAN_CONTROL_API_BASE_PATH=${MERIDIAN_CONTROL_API_BASE_PATH}
MERIDIAN_CONTROL_DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@localhost:${PORT_POSTGRES}/${DB_NAME}_control_plane
MERIDIAN_CONTROL_RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:${PORT_RELAY}
MERIDIAN_CONTROL_COMMUNITY_HOST_PORT=${PORT_RELAY}
RELAY_OPERATOR_API_ORIGIN=http://127.0.0.1:${PORT_RELAY}
MERIDIAN_CONTROL_PLANE_URL=http://127.0.0.1:${PORT_CONTROL_PLANE}${MERIDIAN_CONTROL_API_BASE_PATH}
MERIDIAN_CONTROL_PLANE_ORIGIN=http://127.0.0.1:${PORT_CONTROL_PLANE}
# --- Frontends ---------------------------------------------------------------
MERIDIAN_VITE_PORT=${PORT_DESKTOP_VITE}
MERIDIAN_HMR_PORT=${PORT_DESKTOP_HMR}
MERIDIAN_WEB_PORT=${PORT_WEB}
# --- Compose -----------------------------------------------------------------
COMPOSE_PROFILES=${COMPOSE_PROFILE_LIST}
EOF
printf '%s\n' "$BLOCK_END"
} >"$ENV_LOCAL.tmp"
mv "$ENV_LOCAL.tmp" "$ENV_LOCAL"
dbg "wrote managed block to .env.local"
}
write_compose_override() {
backup_once "$COMPOSE_OVERRIDE"
cat >"$COMPOSE_OVERRIDE" <<EOF
# =============================================================================
# docker-compose.override.yml — GENERATED by ./run.sh (gitignored)
# =============================================================================
# Regenerate with: ./run.sh ports
# Edit the profile instead: .settings/run-profiles/${PROFILE}.yaml
#
# Compose auto-loads this file with no flags and no environment wiring, which is
# why host ports are pinned here as literals rather than left to .env.local:
# this is the only layer that survives a bare \`docker compose up -d\`.
#
# The \`!override\` tags are load-bearing. Compose MERGES sequences by appending,
# so a plain \`ports:\` here would publish the default AND the relocation,
# recreating the very collision this file exists to remove.
#
# .env.local carries the same numbers for the relay and scripts, which read the
# environment directly. \`just compose-check\` fails if the two ever drift.
#
# To stop using this file: mv docker-compose.override.yml{,.disabled}
# =============================================================================
services:
postgres:
ports: !override
- "${PORT_POSTGRES}:5432"
dragonfly:
ports: !override
- "${PORT_REDIS}:6379"
minio:
ports: !override
- "${PORT_MINIO}:9000"
- "${PORT_MINIO_CONSOLE}:9001"
adminer:
ports: !override
- "${PORT_ADMINER}:8080"
keycloak:
ports: !override
- "${PORT_KEYCLOAK}:8080"
prometheus:
ports: !override
- "${PORT_PROMETHEUS}:9090"
# \`bus\` profile only. Compose accepts an override for a profiled service and
# simply filters it out when the profile is off, so this block costs nothing
# until COMPOSE_PROFILES includes \`bus\`.
zenohd:
ports: !override
# 127.0.0.1 is load-bearing. This block is \`!override\`, so it REPLACES the
# base file's ports list — a bare "PORT:7447" here silently undoes the
# loopback bind in docker-compose.yml and republishes the router on every
# interface. A live probe (council C11) injected a forged event from a LAN
# address through an 0.0.0.0-published router and a real client received it
# as authentic.
- "127.0.0.1:${PORT_ZENOH}:7447"
EOF
dbg "wrote docker-compose.override.yml"
}
# Apply the `.env` / `.env.local` layers the way `just`'s `dotenv-load` does.
#
# Only `cmd_desktop`'s non-attach branch reaches `just dev`; the attach branch
# (which `cmd_all` always takes) runs `tauri dev` directly, so the desktop used
# to start with `.env` completely unread. That is why `MERIDIAN_SHARE_IDENTITY`
# set in `.env` never reached `scripts/instance-env.sh` and macOS re-prompted
# for the login keychain on every launch.
#
# A variable that is already set wins and is never overwritten, which keeps the
# documented precedence (`.env` < `.env.local` < shell env) intact and — because
# this runs after `prepare` — leaves every port this script resolved untouched.
# Values are assigned, never `eval`'d: sourcing would let a stray backtick in a
# committed env file execute, and re-exporting a captured environment silently
# creates function-locals under `declare -x`.
load_env_layers() {
local layer line key value preset
# Snapshot the names that were already exported BEFORE any layer is read.
# Testing `${!key+set}` per key instead would let the first layer shadow the
# second, inverting the documented order so `.env` beat `.env.local`.
preset=":$(compgen -e | tr '\n' ':')"
for layer in "$REPO_ROOT/.env" "$ENV_LOCAL"; do
[ -f "$layer" ] || continue
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|'#'*) continue ;; esac
case "$line" in *=*) ;; *) continue ;; esac
key="${line%%=*}"
key="${key##* }" # tolerate a leading `export `
case "$key" in ''|*[!A-Za-z0-9_]*) continue ;; esac
case "$preset" in *":$key:"*) continue ;; esac # caller/prepare wins
value="${line#*=}"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
export "$key=$value"
done <"$layer"
done
}
# Resolve ports, export them, and persist both override layers.
prepare() {
load_profile
resolve_ports
export_env
write_env_local
write_compose_override
}
# =============================================================================
# Probes
# =============================================================================
port_listening() { lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; }
port_owner() { lsof -nP -iTCP:"$1" -sTCP:LISTEN 2>/dev/null | awk 'NR==2{print $1" pid "$2}'; }
port_pids() { lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null; }
http_ok() { curl -fsS --max-time "${2:-5}" "$1" >/dev/null 2>&1; }
pid_alive() { kill -0 "$1" 2>/dev/null; }
# Every descendant of $1, deepest first, so a child dies before its parent and
# nothing is reparented to init halfway through a teardown.
descendants() {
local pid="$1" child
for child in $(pgrep -P "$pid" 2>/dev/null); do
descendants "$child"
printf '%s\n' "$child"
done
}
# Kill one background job and everything it spawned — and nothing else.
#
# The tree is walked from a pid THIS invocation recorded, which is the whole
# point: `pkill -f "$REPO_ROOT/target/debug/meridian-relay"` cannot tell our
# relay from a second agent's, because both were launched from this checkout
# and match the same path. Scoping by binary path is scoping by checkout, not
# by invocation, and on a shared checkout those are different things.
kill_tree() {
local pid="$1" p
[ -n "$pid" ] || return 0
for p in $(descendants "$pid") "$pid"; do
kill "$p" 2>/dev/null
done
return 0
}
# Wait for a port to actually go quiet. Returns 1 if it never does.
wait_port_released() {
local port="$1" tries="${2:-10}" i=0
[ -n "$port" ] || return 0
while [ "$i" -lt "$tries" ]; do
port_listening "$port" || return 0
sleep 1
i=$((i + 1))
done
return 1
}
# A readiness probe answers from whoever owns the port, which need not be the
# process we started. When a second invocation loses the bind race, its own
# child exits with AddrInUse while the winner keeps answering /_readiness: the
# probe goes green over a dead child, the launcher prints "up", and teardown
# then kills the winner's relay. Assert our child is alive before believing it.
assert_started() {
local pid="$1" label="$2" log="$3"
if [ -z "$pid" ] || ! pid_alive "$pid"; then
err "$label answered its readiness probe, but the process we started is gone"
err " something else owns that port — this invocation did not start it"
err " owner: $(port_owner "${4:-}")"
if [ -n "$log" ] && [ -f "$log" ]; then
err " last 40 lines of ${log#$REPO_ROOT/}:"
tail -40 "$log" >&2 || true
fi
return 1
fi
return 0
}
wait_for_http() {
local url="$1" label="$2" tries="${3:-120}" i=0
while [ "$i" -lt "$tries" ]; do
if http_ok "$url" 2; then ok "$label is up"; return 0; fi
sleep 1
i=$((i + 1))
done
err "$label did not come up: $url"
return 1
}
compose() { docker compose "$@"; }
# =============================================================================
# Commands
# =============================================================================
cmd_ports() {
prepare
printf '%sResolved host ports%s (profile: %s)\n\n' "$C_BOLD" "$C_RESET" "$PROFILE"
printf ' %-21s %-7s %-8s %s\n' COMPONENT PORT WANTED NOTE
local key pref actual note
for key in $PORT_KEYS; do
pref="$(pref_of "$key")"; actual="$(port_of "$key")"
note=""
if [ "$pref" = "0" ] || [ -z "$pref" ]; then
pref="auto"
elif [ "$actual" != "$pref" ]; then
note="relocated — $pref busy"
fi
printf ' %-21s %-7s %-8s %s\n' "$key" "$actual" "$pref" "$note"
done
printf '\n relay ws://localhost:%s\n' "$PORT_RELAY"
printf ' web http://localhost:%s\n' "$PORT_WEB"
printf ' desktop http://localhost:%s (vite)\n' "$PORT_DESKTOP_VITE"
printf ' admin http://admin.localhost:%s/reports\n' "$PORT_RELAY"
printf ' health http://localhost:%s/_readiness\n' "$PORT_HEALTH"
printf ' control %s\n' "$CONTROL_UI_URL"
printf ' cp-health %s\n' "$CONTROL_HEALTH_URL"
printf '\n saved to .env.local and docker-compose.override.yml\n'
}
cmd_env() {
prepare >/dev/null
local v
for v in MERIDIAN_RUN_PROFILE MERIDIAN_PG_HOST_PORT REDIS_HOST_PORT ZENOH_HOST_PORT \
MERIDIAN_MINIO_PORT MERIDIAN_MINIO_CONSOLE_PORT MERIDIAN_ADMINER_PORT \
MERIDIAN_KEYCLOAK_PORT MERIDIAN_PROMETHEUS_PORT MERIDIAN_RELAY_PORT \
MERIDIAN_HEALTH_PORT MERIDIAN_METRICS_PORT MERIDIAN_BIND_ADDR \
MERIDIAN_RELAY_URL MERIDIAN_ADMIN_HOST DATABASE_URL REDIS_URL \
MERIDIAN_S3_ENDPOINT PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE \
MERIDIAN_CONTROL_PORT MERIDIAN_CONTROL_HEALTH_PORT \
MERIDIAN_CONTROL_BIND_ADDR MERIDIAN_CONTROL_HEALTH_ADDR \
MERIDIAN_CONTROL_PUBLIC_ORIGIN MERIDIAN_CONTROL_API_BASE_PATH \
MERIDIAN_CONTROL_DATABASE_URL MERIDIAN_CONTROL_RELAY_OPERATOR_API_ORIGIN \
MERIDIAN_CONTROL_COMMUNITY_HOST_PORT RELAY_OPERATOR_API_ORIGIN \
MERIDIAN_CONTROL_PLANE_URL MERIDIAN_CONTROL_PLANE_ORIGIN \
RELAY_URL MERIDIAN_VITE_PORT MERIDIAN_HMR_PORT \
MERIDIAN_WEB_PORT COMPOSE_PROJECT_NAME COMPOSE_PROFILES COMPOSE_ENV_FILES; do
printf 'export %s=%s\n' "$v" "$(getvar "$v")"
done
}
cmd_config() {
load_profile
printf '%sProfile%s %s\n' "$C_BOLD" "$C_RESET" "$PROFILE"
printf ' file %s\n' "${PROFILE_FILE#$REPO_ROOT/}"
printf ' compose project=%s profiles=%s\n' "$COMPOSE_PROJECT" "${COMPOSE_PROFILE_LIST:-<none>}"
printf ' database %s@%s\n' "$DB_USER" "$DB_NAME"
printf '\n%sLayers%s\n' "$C_BOLD" "$C_RESET"
local f
for f in .env .env.local docker-compose.override.yml; do
if [ -f "$REPO_ROOT/$f" ]; then printf ' present %s\n' "$f"; else printf ' absent %s\n' "$f"; fi
done
printf '\n profiles available: '
ls "$PROFILE_DIR"/*.yaml 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/\.yaml$//' | tr '\n' ' '
printf '\n'
}
cmd_doctor() {
local problems=0 t ctxs key pref moved=0 stale legacy env_db
info "Toolchain"
for t in just cargo node pnpm docker python3 curl lsof; do
if have "$t"; then ok "$t"; else err "$t not found"; problems=$((problems + 1)); fi
done
info "Docker"
if have docker && docker info >/dev/null 2>&1; then
ok "daemon reachable (context: $(docker context show 2>/dev/null || echo unknown))"
ctxs="$(docker context ls --format '{{.Name}}' 2>/dev/null | wc -l | tr -d ' ')"
if [ "${ctxs:-0}" -gt 2 ]; then
warn "$ctxs Docker contexts configured — a port published by an inactive"
warn " runtime still occupies the host. run.sh probes for that; a"
warn " 'docker ps' against the active context alone does not."
fi
else
err "Docker daemon not reachable"; problems=$((problems + 1))
fi
load_profile
resolve_ports
info "Port allocation"
for key in $PORT_KEYS; do
pref="$(pref_of "$key")"
if [ -n "$pref" ] && [ "$pref" != "0" ] && [ "$(port_of "$key")" != "$pref" ]; then
warn "$key: wanted $pref, held by [$(port_owner "$pref")] -> using $(port_of "$key")"
moved=$((moved + 1))
fi
done
if [ "$moved" -eq 0 ]; then ok "every preferred port is free"; else ok "$moved port(s) relocated automatically"; fi
# A saved allocation is reused without re-probing, so a port that another
# stack has claimed since would otherwise only surface as a bind failure at
# startup. Anything held by Docker or by this checkout's own processes is
# expected; anything else is a squatter.
local squatters=0 owner
for key in $PORT_KEYS; do
port_listening "$(port_of "$key")" || continue
owner="$(port_owner "$(port_of "$key")")"
case "$owner" in
*docker*|*Docker*|*OrbStack*|*com.docke*|*meridian*|*node*|*vite*|*cargo*) ;;
*) warn "$key port $(port_of "$key") is held by [$owner] — not this stack"
squatters=$((squatters + 1)) ;;
esac
done
if [ "$squatters" -gt 0 ]; then
warn " re-probe with: ./run.sh ports --fresh-ports"
else
ok "no foreign process on any allocated port"
fi
info "Stale pre-rebrand processes"
stale="$(pgrep -fl 'codebase-chat-relay|sprout-relay|buzz-relay' 2>/dev/null || true)"
if [ -n "$stale" ]; then
warn "a pre-rebrand relay is still running:"
printf ' %s\n' "$stale" >&2
warn " it holds this stack's default ports — ./run.sh clean-legacy"
else
ok "no pre-rebrand relay process"
fi
legacy="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^(codebase-chat|sprout|buzz)-' || true)"
if [ -n "$legacy" ]; then
warn "pre-rebrand containers running: $(printf '%s' "$legacy" | tr '\n' ' ')"
warn " ./run.sh clean-legacy to stop them"
else
ok "no pre-rebrand containers"
fi
info "Configuration drift"
# `just`'s dotenv parser is STRICTER than this script's: it rejects an
# unquoted value containing a space and aborts the whole file, so one bad
# line silently strips every variable from every recipe. run.sh keeps
# working (load_env_layers takes the rest of the line verbatim), which is
# exactly what makes the split hard to spot — the launcher is fine while
# `just` is broken. Caught in the wild with
# MERIDIAN_DEV_SIGN_IDENTITY=Meridian Dev.
if command -v just >/dev/null 2>&1; then
if just --evaluate >/dev/null 2>&1; then
ok ".env parses for just"
else
err ".env does not parse for just — EVERY just recipe fails:"
just --evaluate 2>&1 | grep -i "environment file" | head -1 |
sed 's/^/ /' >&2
warn " values containing spaces must be quoted: KEY=\"two words\""
problems=$((problems + 1))
fi
fi
if [ -f "$REPO_ROOT/.env" ]; then
env_db="$(grep -E '^DATABASE_URL=' "$REPO_ROOT/.env" | tail -1 || true)"
case "$env_db" in
*codebase_chat*|*sprout*|*buzz*)
warn ".env carries a pre-rebrand DATABASE_URL:"
printf ' %s\n' "$env_db" >&2
warn " .env.local overrides it so the relay is fine, but fix .env"
warn " so other tooling does not dial the wrong database."
;;
*) ok ".env DATABASE_URL looks current" ;;
esac
fi
if [ -f "$ENV_LOCAL" ] && ! saved_ports | grep -q .; then
warn ".env.local has no run.sh block yet — run ./run.sh ports"
else
ok "override layers are in place"
fi
printf '\n'
if [ "$problems" -eq 0 ]; then
ok "environment is ready — next: ./run.sh all"
else
err "$problems blocking problem(s)"
return 1
fi
}
cmd_clean_legacy() {
load_profile
local stale_pids legacy reply pid name
stale_pids="$(pgrep -f 'codebase-chat-relay|sprout-relay|buzz-relay' 2>/dev/null || true)"
legacy="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^(codebase-chat|sprout|buzz)-' || true)"
if [ -z "$stale_pids" ] && [ -z "$legacy" ]; then
ok "nothing to clean — no pre-rebrand processes or containers"
return 0
fi
[ -n "$stale_pids" ] && { warn "pre-rebrand relay processes:"; pgrep -fl 'codebase-chat-relay|sprout-relay|buzz-relay' >&2 || true; }
[ -n "$legacy" ] && { warn "pre-rebrand containers:"; printf ' %s\n' $legacy >&2; }
if [ "$ASSUME_YES" -ne 1 ]; then
printf 'Stop these? [y/N] '
read -r reply
case "$reply" in [Yy]*) ;; *) info "left untouched"; return 0 ;; esac
fi
for pid in $stale_pids; do
if kill "$pid" 2>/dev/null; then ok "stopped process $pid"; else warn "could not stop $pid"; fi
done
for name in $legacy; do
if docker stop "$name" >/dev/null 2>&1; then ok "stopped container $name"; else warn "could not stop $name"; fi
done
}
cmd_up() {
prepare
have docker || die "docker not found"
docker info >/dev/null 2>&1 || die "Docker daemon not reachable"
info "Starting backing services (project: $COMPOSE_PROJECT, profiles: ${COMPOSE_PROFILE_LIST:-none})"
compose up -d
info "Waiting for health"
local i=0 pg df
while [ "$i" -lt 60 ]; do
pg="$(docker inspect --format '{{.State.Health.Status}}' "${COMPOSE_PROJECT}-postgres" 2>/dev/null || echo missing)"
df="$(docker inspect --format '{{.State.Health.Status}}' "${COMPOSE_PROJECT}-dragonfly" 2>/dev/null || echo missing)"
if [ "$pg" = healthy ] && [ "$df" = healthy ]; then
ok "postgres healthy on $PORT_POSTGRES"
ok "dragonfly healthy on $PORT_REDIS"
return 0
fi
sleep 2
i=$((i + 1))
done
err "services did not become healthy (postgres=$pg dragonfly=$df)"
compose ps
return 1
}
cmd_down() { prepare >/dev/null; info "Stopping services"; compose down; ok "stopped (volumes kept)"; }
cmd_logs() { prepare >/dev/null; compose logs -f "$@"; }
cmd_status() {
prepare >/dev/null
info "Containers"
compose ps 2>/dev/null || warn "compose unavailable"
info "Components"
if http_ok "http://localhost:${PORT_HEALTH}/_readiness" 2; then ok "relay ws://localhost:$PORT_RELAY"; else warn "relay not responding ($PORT_RELAY)"; fi
if port_listening "$PORT_WEB"; then ok "web http://localhost:$PORT_WEB"; else warn "web not running ($PORT_WEB)"; fi
# The dev vite port only exists under `tauri dev`. A bundled Meridian.app
# serves its frontend from inside the bundle, so reporting "desktop not
# running" on that port alone is wrong whenever the .app is what is running.
if port_listening "$PORT_DESKTOP_VITE"; then
ok "desktop http://localhost:$PORT_DESKTOP_VITE (dev)"
elif pgrep -f 'Meridian.app/Contents/MacOS/meridian-desktop' >/dev/null 2>&1; then
ok "desktop Meridian.app (bundled — no dev server)"
else
warn "desktop not running"
fi
if http_ok "$CONTROL_HEALTH_URL" 2; then ok "control $CONTROL_UI_URL"; else warn "control not responding ($PORT_CONTROL_PLANE)"; fi
if http_ok "http://localhost:${PORT_METRICS}/metrics" 2; then ok "metrics http://localhost:$PORT_METRICS/metrics"; else warn "metrics not responding ($PORT_METRICS)"; fi
}
cmd_migrate() {
prepare
info "Applying migrations against localhost:$PORT_POSTGRES"
just _ensure-migrations
ok "migrations applied and local community seeded"
}
cmd_build() {
prepare
info "Building Rust workspace"
if [ "$RELEASE" -eq 1 ]; then cargo build --workspace --release; else cargo build --workspace; fi
info "Building desktop sidecar binaries"
just _ensure-sidecar-stubs
ok "build complete"
}
cmd_relay() {
prepare
cmd_up
# A healthy relay already on these ports means there is nothing to do.
# Starting a second one does not fail cleanly: the WebSocket port is the
# only one checked here, while the metrics exporter panics outright on a
# duplicate bind ("metrics exporter must build exactly once"), taking down
# the new process with exit 101 and leaving the original running. That reads
# as a relay crash rather than a double start.
if http_ok "http://localhost:${PORT_HEALTH}/_readiness" 2; then
ok "relay is already running on ws://localhost:$PORT_RELAY"
printf ' health http://localhost:%s/_readiness\n' "$PORT_HEALTH"
printf ' stop it with: ./run.sh stop\n'
return 0
fi
if port_listening "$PORT_RELAY"; then
die "port $PORT_RELAY is held by [$(port_owner "$PORT_RELAY")]; run ./run.sh ports --fresh-ports"
fi
# The metrics and health ports are fatal on collision too, so check them
# before handing off to cargo rather than after a two-minute build.
local p
for p in "$PORT_HEALTH" "$PORT_METRICS"; do
if port_listening "$p"; then
die "port $p is held by [$(port_owner "$p")]; run ./run.sh ports --fresh-ports"
fi
done
info "Relay on ws://localhost:$PORT_RELAY (health $PORT_HEALTH, metrics $PORT_METRICS)"
if [ "$RELEASE" -eq 1 ]; then just relay-release; else just relay; fi
}
# `CREATE DATABASE` has no IF NOT EXISTS, and the control plane keeps its own
# database rather than sharing the relay's — two migration histories in one
# database corrupt both. Compose has a dedicated one-shot for this; the host
# path needs the same guard or `--migrate-only` fails on a fresh volume.
ensure_control_plane_db() {
local db="${DB_NAME}_control_plane"
if psql -tAc "SELECT 1 FROM pg_database WHERE datname='${db}'" postgres 2>/dev/null | grep -q 1; then
dbg "control plane database ${db} exists"
return 0
fi
info "Creating the control plane database ${db}"
createdb "$db" 2>/dev/null || psql -q -c "CREATE DATABASE \"${db}\"" postgres || {
err "could not create ${db} on localhost:${PORT_POSTGRES}"
return 1
}
}
cmd_control_plane() {
prepare
cmd_up
if port_listening "$PORT_CONTROL_PLANE" && ! http_ok "http://127.0.0.1:${PORT_CONTROL_PLANE_HEALTH}/health/ready" 2; then
die "port $PORT_CONTROL_PLANE is held by [$(port_owner "$PORT_CONTROL_PLANE")]; run ./run.sh ports --fresh-ports"
fi
# The relay must already trust this control plane's operator key, or every
# provisioning call is rejected as unauthorized. `just control-plane`
# generates the key and prints the exact lines when they are missing.
local key_file=".control-plane/operator.key"
if [ -f "$key_file" ] && [ -f .env ]; then
local pubkey
pubkey="$(tr -d '[:space:]' <".control-plane/operator.pub" 2>/dev/null || true)"
if [ -n "$pubkey" ] && ! grep -q "^RELAY_OPERATOR_PUBKEYS=.*$pubkey" .env 2>/dev/null; then
warn "the relay does not list this control plane's operator pubkey"
warn " add to .env: RELAY_OPERATOR_PUBKEYS=$pubkey"
fi
fi
ensure_control_plane_db || return 1
info "Control plane on $MERIDIAN_CONTROL_PLANE_URL (health $PORT_CONTROL_PLANE_HEALTH)"
info "Relays resolve at <name>.relays.meridian.localtest.me:$PORT_RELAY"
just control-plane
}
cmd_web() {
prepare
info "Web client on http://localhost:$PORT_WEB against $MERIDIAN_RELAY_URL"
[ -d node_modules ] || pnpm install
cd "$REPO_ROOT/REMAPPING/meridian-web"
VITE_RELAY_URL="$MERIDIAN_RELAY_URL" exec pnpm exec vite --port "$PORT_WEB" --strictPort
}
# The desktop app restores its window geometry from .window-state.json, and the
# app-data migration copies that file forward from the pre-rebrand identifier on
# every launch. If those coordinates came from a display arrangement that is no
# longer attached, the window opens off-screen: the process runs, no error is
# logged, and nothing appears. Reset the file rather than leave that mystery.
reset_offscreen_window_state() {
local identifier="$1" state_file bounds max_x max_y x y
[ -n "$identifier" ] || return 0
state_file="$HOME/Library/Application Support/${identifier}/.window-state.json"
[ -f "$state_file" ] || return 0
bounds="$(osascript -e 'tell application "Finder" to get bounds of window of desktop' 2>/dev/null)"
max_x="$(printf '%s' "$bounds" | awk -F', *' '{print $3}')"
max_y="$(printf '%s' "$bounds" | awk -F', *' '{print $4}')"
[ -n "$max_x" ] && [ -n "$max_y" ] || return 0
x="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['main']['x'])" "$state_file" 2>/dev/null)"
y="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['main']['y'])" "$state_file" 2>/dev/null)"
[ -n "$x" ] && [ -n "$y" ] || return 0
# A window is unreachable once its origin sits past the desktop's extent, or
# far enough above it that the title bar cannot be grabbed.
if [ "$x" -ge "$max_x" ] || [ "$y" -ge "$max_y" ] || [ "$x" -lt -200 ] || [ "$y" -lt -100 ]; then
warn "saved window position ${x},${y} is outside this display (${max_x}x${max_y})"
warn " resetting it, or the app would start with no visible window"
rm -f "$state_file"
fi
return 0
}
# Compile the desktop with the shared-compute runtime (`mesh-llm`).
#
# Off by default for the same reason `just` keeps it off (`mesh=1` there): it
# pulls ~420 extra crates plus a llama.cpp native runtime build. The cost of
# leaving it off is NOT invisible — every mesh_* Tauri command becomes the stub
# in src-tauri/src/mesh_llm_stubs.rs, so Settings > Compute reports "Not
# included in this build" and the Share switch cannot be turned on.
#
# MESH_LLM_NATIVE_RUNTIME_CACHE_DIR is what the build resolves the Metal (macOS)
# or CPU runtime through, exactly as the `just mesh=1 dev|staging|production`
# recipes set it. Preparing it is what makes the FIRST mesh build slow; later
# ones hit the cache under .cache/mesh-llm-native-runtime.
#
# Sets MESH_FEATURE_ARGS for the caller — empty when off, so every call site can
# splat it unconditionally.
MESH_FEATURE_ARGS=()
mesh_prepare() {
MESH_FEATURE_ARGS=()
[ "$MESH" -eq 1 ] || return 0
info "Shared compute on (mesh-llm) — the first build of this is slow"
MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$("$REPO_ROOT/scripts/ensure-mesh-native-runtime.sh")" ||
die "could not prepare the mesh-llm native runtime; re-run without --mesh to build without shared compute"
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR
MESH_FEATURE_ARGS=(--features mesh-llm)
}
# Publish the prepared runtime where a BUNDLED app will actually look for it.
#
# MESH_LLM_NATIVE_RUNTIME_CACHE_DIR is read at RUNTIME (std::env::var_os in
# mesh-llm-runtime-install's `native_runtime_cache`), not baked at build time —
# and a GUI-launched .app inherits no shell environment. So exporting it around
# `tauri build` steers nothing: the bundle resolves
# `dirs::cache_dir()/mesh-llm/native-runtimes` instead, finds nothing, and falls
# back to DOWNLOADING the runtime from an upstream host the first time a member
# shares. That is the same class of trap as the MNS endpoint, which is why that
# one is baked with `option_env!`.
#
# Copying the runtime this build just compiled into the default root closes it:
# the bundle shares offline, against the pinned rev, without reaching a third
# party. It is a cache directory the app would otherwise populate itself, so
# this is a warm start, not new state.
mesh_publish_runtime() {
[ "$MESH" -eq 1 ] || return 0
local src="${MESH_LLM_NATIVE_RUNTIME_CACHE_DIR:-}" dest manifest dir id version
[ -n "$src" ] && [ -d "$src" ] || return 0
case "$(uname -s)" in
Darwin) dest="$HOME/Library/Caches/mesh-llm/native-runtimes" ;;
*) dest="${XDG_CACHE_HOME:-$HOME/.cache}/mesh-llm/native-runtimes" ;;
esac
# Layout is <root>/<mesh_version>/<runtime_id>/, per NativeRuntimeCache
# ::runtime_dir and what ensure-mesh-native-runtime.sh writes.
for manifest in "$src"/*/*/manifest.json; do
[ -f "$manifest" ] || continue
dir="$(dirname "$manifest")"
id="$(basename "$dir")"
version="$(basename "$(dirname "$dir")")"
mkdir -p "$dest/$version"
rm -rf "$dest/$version/$id"
cp -a "$dir" "$dest/$version/$id"
ok "native runtime $id ($version) published for bundled launches"
done
}
cmd_desktop() {
prepare
if [ "$ATTACH" -eq 1 ]; then
http_ok "http://localhost:${PORT_HEALTH}/_readiness" 2 ||
die "no relay on $PORT_RELAY to attach to; start one with ./run.sh relay"
info "Desktop attaching to ws://localhost:$PORT_RELAY (vite $PORT_DESKTOP_VITE)"
if [ "$NO_BUILD" -ne 1 ]; then
just _ensure-sidecar-stubs
cargo build -p meridian-acp -p meridian-agent -p meridian-dev-mcp -p meridian-cli -p git-credential-nostr
fi
cd "$REPO_ROOT/REMAPPING/meridian-desktop"
[ -d node_modules ] || pnpm install
# Parity with the `just dev` branch below, which gets `.env` from just's
# dotenv-load. Must precede instance-env.sh: that is the reader of
# MERIDIAN_SHARE_IDENTITY, and without it the desktop opens the OS
# keyring on every launch.
load_env_layers
# shellcheck disable=SC1091
. "$REPO_ROOT/scripts/instance-env.sh"
reset_offscreen_window_state \
"$(node -e "console.log(JSON.parse(process.env.MERIDIAN_TAURI_CONFIG).identifier)" 2>/dev/null)"
# Stable dev code signing, mirroring the `just dev` recipe. This branch
# never reaches that recipe, so without this the launcher's headline
# command (`./run.sh all`) keeps the ad-hoc identity whose designated
# requirement is a bare cdhash — it changes on every relink, so every OS
# grant (keychain, firewall accept-incoming, local network, TCC)
# re-prompts after each rebuild. Signed, the requirement becomes
# `identifier "..." and certificate leaf = H"..."`, which is byte-identical
# across rebuilds, so one grant holds. Setup: REMAPPING/meridian-desktop/src-tauri/AGENTS.md.
if [ "$(uname -s)" = "Darwin" ] && [ -n "${MERIDIAN_DEV_SIGN_IDENTITY:-}" ]; then
export "CARGO_TARGET_$(rustc -vV | sed -n 's|host: ||p' | tr 'a-z-' 'A-Z_')_RUNNER=$REPO_ROOT/REMAPPING/meridian-desktop/src-tauri/scripts/macos-dev-sign-runner.sh"
info "Stable dev signing as '$MERIDIAN_DEV_SIGN_IDENTITY'"
fi
# MERIDIAN_DEV_FILE_SECRETS=1 builds without the `system-keyring`
# feature, so the desktop never opens the macOS login keychain and
# cannot prompt. Necessary because MERIDIAN_SHARE_IDENTITY only removes
# the identity read, while managed_agents::{migrate_agent_keys_to_dev_service,
# hydrate_keys} open the same blob on every debug boot — and neither the
# item's ACL nor its partition list can be made permissive unattended
# (the latter needs the login password). Secrets are migrated into the
# 0600 file stores the app already falls back to; see the script header
# for the trade that makes.
dev_features=()
if [ "${MERIDIAN_DEV_FILE_SECRETS:-0}" = "1" ]; then
if ! "$REPO_ROOT/scripts/dev-secrets-to-files.sh" --check >/dev/null 2>&1; then
info "Migrating dev secrets into file stores (keychain left untouched)"
"$REPO_ROOT/scripts/dev-secrets-to-files.sh" ||
die "could not migrate dev secrets; unset MERIDIAN_DEV_FILE_SECRETS to use the keychain"
fi
# Tauri v2's `dev` has NO --no-default-features of its own; only
# `-f/--features`. Cargo flags reach the runner only AFTER `--`
# ("Command line arguments passed to the runner"), so passing it as
# a tauri option instead aborts the launch with an unknown-argument
# error. Verified with `--runner echo`: this yields
# `cargo run --no-default-features --color always --`.
dev_features=(-- --no-default-features)
info "Keychain-free dev build (MERIDIAN_DEV_FILE_SECRETS=1)"
fi
# `--features` IS a tauri option (unlike --no-default-features above),
# so it goes BEFORE the `--`; dev_features must stay last.
mesh_prepare
# Deliberately not `exec`: `all` runs this as its foreground step and
# relies on the EXIT trap to stop the relay and web server it started.
# exec would replace this shell and the trap would never fire, leaving
# both processes orphaned on their ports.
pnpm exec tauri dev --config "$MERIDIAN_TAURI_CONFIG" \
${MESH_FEATURE_ARGS[@]+"${MESH_FEATURE_ARGS[@]}"} \
${dev_features[@]+"${dev_features[@]}"}
return $?
fi
cmd_up
info "Desktop app with its own relay on ws://localhost:$PORT_RELAY"
# `just` owns this branch's build, and it spells the same toggle `mesh=1`.
if [ "$MESH" -eq 1 ]; then just mesh=1 dev; else just dev; fi
}
# Produce a launchable .app. `just desktop-release-build` only `touch`es the
# sidecar paths, which satisfies Tauri's externalBin check with ZERO-BYTE files —
# the bundle then builds, installs, and fails at runtime the first time it shells
# out to an agent binary. Build the sidecars for real and stage them instead.
# Sign a built .app with the stable dev identity, inside-out.
#
# `tauri build` leaves the bundle ad-hoc signed unless an identity is
# configured, so its designated requirement is a bare cdhash that changes on
# every build — and the bundle is what QA actually launches. None of the
# MERIDIAN_DEV_* escape hatches can help there: a GUI-launched .app inherits no
# shell environment, so the keychain, firewall, local-network and TCC grants all
# re-prompt after each rebuild. Signing here rather than relying on the CLI's
# own signing keeps this independent of whether it honours APPLE_SIGNING_IDENTITY.
#
# Nested executables MUST be signed before the bundle: sealing the bundle
# records their hashes, so re-signing one afterwards invalidates the outer
# signature. Failure warns and leaves the ad-hoc bundle rather than aborting a
# build that is otherwise fine.
sign_app_bundle() {
local app="$1" identity="${MERIDIAN_DEV_SIGN_IDENTITY:-}" nested
[ "$(uname -s)" = "Darwin" ] || return 0
[ -d "$app" ] || return 0
if [ -z "$identity" ]; then
warn "MERIDIAN_DEV_SIGN_IDENTITY unset: the bundle stays ad-hoc signed and"
warn " every OS grant will re-prompt after the next build."
warn " Fix once with: just dev-signing-identity"
return 0
fi
for nested in "$app/Contents/MacOS/"*; do
[ -f "$nested" ] && [ -x "$nested" ] || continue
codesign --force --sign "$identity" "$nested" >/dev/null 2>&1 ||
warn "codesign failed for $(basename "$nested")"
done
if codesign --force --sign "$identity" "$app" >/dev/null 2>&1 &&
codesign --verify "$app" >/dev/null 2>&1; then
ok "signed as '$identity' — requirement is stable across rebuilds"
else
warn "could not sign $app; OS grants will re-prompt after each build"
fi
}
cmd_bundle() {
prepare
# sign_app_bundle reads MERIDIAN_DEV_SIGN_IDENTITY, which lives in
# .env.local; only cmd_desktop loaded the layers before, so a bundle built
# here would have silently stayed ad-hoc signed.
load_env_layers
local bundles="app"
[ "$FULL" -eq 1 ] && bundles="app,dmg"
if [ "$NO_BUILD" -ne 1 ]; then
info "Building release sidecar binaries"
cargo build --release \
-p meridian-acp -p meridian-agent -p meridian-dev-mcp \
-p git-credential-nostr -p meridian-cli
info "Staging sidecars into REMAPPING/meridian-desktop/src-tauri/binaries"
./scripts/bundle-sidecars.sh
fi
mesh_prepare
# Unlike the dev path, a bundle cannot read the exported cache dir — see
# mesh_publish_runtime. Do it before the build so a failure surfaces before
# the 19-minute release compile, not after.
mesh_publish_runtime
info "Bundling ($bundles) — this compiles the app in release mode"
( cd "$REPO_ROOT/REMAPPING/meridian-desktop" &&
pnpm tauri build --bundles "$bundles" ${MESH_FEATURE_ARGS[@]+"${MESH_FEATURE_ARGS[@]}"} )
local app="$REPO_ROOT/REMAPPING/meridian-desktop/src-tauri/target/release/bundle/macos/Meridian.app"
if [ -d "$app" ]; then
ok "built ${app#$REPO_ROOT/}"
sign_app_bundle "$app"
printf ' open it with: open "%s"\n' "$app"
else
err "expected bundle not found at ${app#$REPO_ROOT/}"
return 1
fi
# A bundle carrying the previous product name is indistinguishable from the
# current one by version alone — both read 0.5.2 here. Say so rather than
# leave it to be installed by mistake.
local stale
stale="$(find "$REPO_ROOT/REMAPPING/meridian-desktop/src-tauri/target" -maxdepth 5 -name '*.app' \
-not -name 'Meridian.app' 2>/dev/null | head -5)"
if [ -n "$stale" ]; then
warn "stale bundles from a previous product name are still on disk:"
printf ' %s\n' $stale >&2
warn " delete them so the wrong app cannot be shipped or launched"
fi
}
cmd_admin() {
prepare
cmd_up
info "Admin dashboard at http://admin.localhost:$PORT_RELAY/reports"
just admin
}
RELAY_BG_PID=""
WEB_BG_PID=""
CONTROL_PLANE_BG_PID=""
# Killing the backgrounded `just` only kills the recipe shell — cargo and the
# relay binary it spawned keep the port bound, so the next launch finds the
# relay port occupied by a process nothing appears to own. Take the children
# down too, by walking OUR pid tree.
#
# This trap fires automatically on every exit, including the exit of an
# invocation that lost a bind race and never owned anything. It must therefore
# only ever touch processes this invocation started; the previous `pkill -f
# <binary path>` matched a second agent's relay just as well as ours and killed
# it on the way out. An explicit `./run.sh stop` may reclaim orphans by port —
# see cmd_stop — but an EXIT trap may not.
stop_bg() {
kill_tree "$RELAY_BG_PID"
kill_tree "$WEB_BG_PID"
kill_tree "$CONTROL_PLANE_BG_PID"
release_frontend_ports
return 0
}
# `tauri dev` spawns vite as beforeDevCommand; killing tauri leaves that vite
# orphaned and still holding --strictPort. The next launch then dies with
# "Port NNNNN is already in use" and points at a process nothing appears to own.
#
# Reap by port owner rather than by command line, but only when that owner is
# in THIS invocation's process tree. Ports are resolved per checkout, so a
# second agent's vite answers to the same `vite --port NNNNN` pattern and to the
# same port number — the process tree is the only thing that distinguishes them.
release_frontend_ports() {
local port owner mine
mine=" $(descendants $$ | tr '\n' ' ') "
for port in "$PORT_DESKTOP_VITE" "$PORT_WEB"; do
[ -n "$port" ] || continue
for owner in $(port_pids "$port"); do
case "$mine" in
*" $owner "*) kill "$owner" 2>/dev/null ;;
esac
done
done
return 0
}
# Verify a port actually went quiet, and reclaim it from its real owner if not.
#
# A tracked pid is the `just`/pnpm wrapper, which can exit or be replaced while
# its child keeps the socket. Killing the wrapper and reporting ok is how an
# orphaned relay from an earlier build ends up answering health probes for a
# fresh one: every probe is green, `status` and `e2e` pass, and the binary under
# test is yesterday's. So verify the port, not the pid.
reclaim_port() {
local port="$1" label="$2" owner
[ -n "$port" ] || return 0
port_listening "$port" || return 0
for owner in $(port_pids "$port"); do
warn "$label still bound on $port by pid $owner ($(ps -o comm= -p "$owner" 2>/dev/null | tr -d ' ')) — reclaiming"
kill_tree "$owner"
done
if wait_port_released "$port" 10; then
ok "released $label port $port"
return 0
fi
err "$label port $port is STILL bound after reclaim: $(port_owner "$port")"
err " another stack from this checkout may be live, or the port belongs to another project"
return 1
}
cmd_all() {
prepare
mkdir -p "$LOG_DIR" "$PID_DIR"
cmd_up
trap stop_bg EXIT INT TERM
info "Starting relay in the background (log: ${LOG_DIR#$REPO_ROOT/}/relay.log)"
just relay >"$LOG_DIR/relay.log" 2>&1 &
RELAY_BG_PID=$!
printf '%s\n' "$RELAY_BG_PID" >"$PID_DIR/relay.pid"
if ! wait_for_http "http://localhost:${PORT_HEALTH}/_readiness" "relay" 240; then
err "relay failed to start — last 40 log lines:"
tail -40 "$LOG_DIR/relay.log" >&2 || true
return 1
fi
assert_started "$RELAY_BG_PID" "relay" "$LOG_DIR/relay.log" "$PORT_HEALTH" || return 1
# Started after the relay: it verifies the operator handshake against the
# relay's origin on the way up, so a control plane that boots first only
# reports a failure the relay would have answered a second later.
if ensure_control_plane_db; then
info "Starting control plane in the background (log: ${LOG_DIR#$REPO_ROOT/}/control-plane.log)"
just control-plane >"$LOG_DIR/control-plane.log" 2>&1 &
CONTROL_PLANE_BG_PID=$!
printf '%s\n' "$CONTROL_PLANE_BG_PID" >"$PID_DIR/control-plane.pid"
if ! wait_for_http "http://127.0.0.1:${PORT_CONTROL_PLANE_HEALTH}/health/ready" "control plane" 180; then
warn "control plane did not come up — community creation will be unavailable"
warn " last 20 log lines:"
tail -20 "$LOG_DIR/control-plane.log" >&2 || true
elif ! assert_started "$CONTROL_PLANE_BG_PID" "control plane" \
"$LOG_DIR/control-plane.log" "$PORT_CONTROL_PLANE_HEALTH"; then
# Ours lost the bind race; another invocation's control plane is
# answering. Forget the dead pid so the EXIT trap cannot walk a
# recycled one, and carry on degraded rather than claiming a stack.
CONTROL_PLANE_BG_PID=""
warn "continuing without a control plane owned by this invocation"
fi
else
warn "skipping the control plane — community creation will be unavailable"
fi
info "Starting web client in the background (log: ${LOG_DIR#$REPO_ROOT/}/web.log)"
( cd "$REPO_ROOT/REMAPPING/meridian-web" && VITE_RELAY_URL="$MERIDIAN_RELAY_URL" \
pnpm exec vite --port "$PORT_WEB" --strictPort ) >"$LOG_DIR/web.log" 2>&1 &
WEB_BG_PID=$!
printf '%s\n' "$WEB_BG_PID" >"$PID_DIR/web.pid"
printf '\n%sStack is up%s\n' "$C_BOLD" "$C_RESET"
printf ' relay ws://localhost:%s\n' "$PORT_RELAY"
printf ' web http://localhost:%s\n' "$PORT_WEB"
printf ' admin http://admin.localhost:%s/reports\n' "$PORT_RELAY"
printf ' health http://localhost:%s/_readiness\n' "$PORT_HEALTH"
printf ' control %s\n\n' "$CONTROL_UI_URL"
info "Starting the desktop app in the foreground (Ctrl-C stops everything)"
ATTACH=1
cmd_desktop
}
# Unlike the EXIT trap, this is a human saying "clear this checkout", so it may
# reclaim a port from an orphan it did not start. What it may NOT do is report
# success without checking: the old version killed the tracked pid and printed
# "ok" whether or not the socket was ever released, which is how a stale relay
# survives a stop and then answers probes for the next build.
cmd_stop() {
local f pid name failed=0
# Needed for PORT_* — the reclaim path resolves owners by exact port.
prepare >/dev/null
for f in "$PID_DIR"/*.pid; do
[ -f "$f" ] || continue
name="$(basename "$f" .pid)"
pid="$(cat "$f" 2>/dev/null)"
if [ -n "$pid" ] && pid_alive "$pid"; then
kill_tree "$pid"
ok "stopped $name (pid $pid)"
else
info "$name pidfile was stale (pid ${pid:-none} not running)"
fi
rm -f "$f"
done
# The relay binary outlives the `just` process that launched it, and vite
# outlives `tauri dev`. Both keep their ports bound if left behind, so the
# port is the thing to assert on.
reclaim_port "$PORT_HEALTH" "relay health" || failed=1
reclaim_port "$PORT_RELAY" "relay" || failed=1
reclaim_port "$PORT_CONTROL_PLANE_HEALTH" "control plane health" || failed=1
reclaim_port "$PORT_CONTROL_PLANE" "control plane" || failed=1
reclaim_port "$PORT_WEB" "web" || failed=1
reclaim_port "$PORT_DESKTOP_VITE" "desktop vite" || failed=1
if [ "$failed" -ne 0 ]; then
err "stop did NOT fully release this checkout's ports — see above"
err " do not trust a green ./run.sh status or ./run.sh e2e until it does"
return 1
fi
ok "background processes stopped (containers still up; ./run.sh down for those)"
}
cmd_qa() {
prepare >/dev/null
if [ "$FULL" -eq 1 ]; then
info "Full CI gate"
just ci
ok "just ci passed"
return 0
fi
info "Format and lint"
just fmt-check
just clippy
just desktop-check
just web-check
info "Local stack contract"
just compose-check
info "Unit tests"
just test-unit
ok "QA gates passed"
}
E2E_FAILURES=0
e2e_check() {
local label="$1"; shift
if "$@" >/dev/null 2>&1; then ok "$label"; else err "$label"; E2E_FAILURES=$((E2E_FAILURES + 1)); fi
}
cmd_e2e() {
prepare >/dev/null
E2E_FAILURES=0
info "Backing services"
e2e_check "postgres accepts connections on $PORT_POSTGRES" \
docker exec "${COMPOSE_PROJECT}-postgres" pg_isready -U "$DB_USER"
e2e_check "postgres serves the '$DB_NAME' database" \
docker exec "${COMPOSE_PROJECT}-postgres" psql -U "$DB_USER" -d "$DB_NAME" -c 'select 1'
e2e_check "dragonfly answers PING on $PORT_REDIS" \
docker exec "${COMPOSE_PROJECT}-dragonfly" redis-cli ping
e2e_check "minio is live on $PORT_MINIO" \
curl -fsS --max-time 5 "http://localhost:${PORT_MINIO}/minio/health/live"
info "Relay"
e2e_check "liveness probe on $PORT_HEALTH" \
curl -fsS --max-time 5 "http://localhost:${PORT_HEALTH}/_liveness"
e2e_check "readiness probe on $PORT_HEALTH" \
curl -fsS --max-time 5 "http://localhost:${PORT_HEALTH}/_readiness"
e2e_check "metrics endpoint on $PORT_METRICS" \
curl -fsS --max-time 5 "http://localhost:${PORT_METRICS}/metrics"
# Readiness reports per-lane subscriber health. "ready" in every lane is the
# difference between "the process is listening" and "fan-out actually works".
if curl -fsS --max-time 5 "http://localhost:${PORT_HEALTH}/_readiness" 2>/dev/null |
grep -q '"status":"ready"'; then
ok "pubsub lanes report ready"
else
err "pubsub lanes are not ready"; E2E_FAILURES=$((E2E_FAILURES + 1))
fi
# NIP-11 relay information document — the relay's identity over HTTP.
if curl -fsS --max-time 5 -H 'Accept: application/nostr+json' \
"http://localhost:${PORT_RELAY}/" 2>/dev/null | grep -q '"name"'; then
ok "NIP-11 document served on $PORT_RELAY"
else
err "NIP-11 document on $PORT_RELAY"; E2E_FAILURES=$((E2E_FAILURES + 1))
fi
# The generic Nostr bridge. Unauthenticated, the correct answer is 401 —
# that single status proves three things at once: the route is mounted, the
# host resolved to a community row (a bad Host fails closed with 404), and
# the NIP-42 auth pipeline runs. A 200 here would mean auth is NOT enforced.
local query_status
query_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \
-X POST "http://localhost:${PORT_RELAY}/query" \
-H 'Content-Type: application/json' -d '{"kinds":[39000],"limit":1}' 2>/dev/null || echo 000)"
case "$query_status" in
401) ok "POST /query enforces Nostr auth (401)" ;;
404) err "POST /query returned 404 — Host localhost:${PORT_RELAY} has no community row; re-run ./run.sh migrate"
E2E_FAILURES=$((E2E_FAILURES + 1)) ;;
200) err "POST /query returned 200 unauthenticated — auth is not being enforced"
E2E_FAILURES=$((E2E_FAILURES + 1)) ;;
*) err "POST /query returned unexpected status $query_status"
E2E_FAILURES=$((E2E_FAILURES + 1)) ;;
esac
info "Control plane"
if port_listening "$PORT_CONTROL_PLANE"; then
e2e_check "health probe on $PORT_CONTROL_PLANE_HEALTH" \
curl -fsS --max-time 5 "$CONTROL_HEALTH_URL"
e2e_check "relays UI on $PORT_CONTROL_PLANE" \
curl -fsS --max-time 5 "$CONTROL_UI_URL"
e2e_check "client-config API responds" \
curl -fsS --max-time 5 "${MERIDIAN_CONTROL_PLANE_URL}/v1/meridian/client-config"
# Unauthenticated session lookup must be refused, not answered.
local sess
sess="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \
"${MERIDIAN_CONTROL_PLANE_URL}/v1/auth/session" 2>/dev/null || echo 000)"
if [ "$sess" = "401" ]; then
ok "auth/session refuses an unauthenticated caller (401)"
else
err "auth/session returned $sess, expected 401"; E2E_FAILURES=$((E2E_FAILURES + 1))
fi
else
warn "control plane not running on $PORT_CONTROL_PLANE — skipped"
fi
info "Web client"
if port_listening "$PORT_WEB"; then
e2e_check "web dev server responds on $PORT_WEB" \
curl -fsS --max-time 5 "http://localhost:${PORT_WEB}/"
else
warn "web dev server not running on $PORT_WEB — skipped"
fi
if [ "$FULL" -eq 1 ]; then
info "Browser smoke suites"
just web-e2e-smoke
just desktop-e2e-smoke
ok "Playwright smoke suites passed"
fi
printf '\n'
if [ "$E2E_FAILURES" -eq 0 ]; then
ok "end-to-end checks passed"
else
err "$E2E_FAILURES end-to-end check(s) failed"
return 1
fi
}
cmd_reset() {
prepare >/dev/null
local reply
if [ "$ASSUME_YES" -ne 1 ]; then
warn "This wipes local dev state (containers, volumes, desktop state)."
printf 'Continue? [y/N] '
read -r reply
case "$reply" in [Yy]*) ;; *) info "aborted"; return 0 ;; esac
fi
just reset
ok "reset complete"
}
# =============================================================================
# Argument parsing
# =============================================================================
COMMAND=""
EXTRA=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--verbose) VERBOSE=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
--profile) PROFILE="${2:?--profile needs a name}"; shift 2 ;;
--profile=*) PROFILE="${1#*=}"; shift ;;
--fresh-ports|--fresh) FRESH_PORTS=1; shift ;;
--no-build) NO_BUILD=1; shift ;;
--release) RELEASE=1; shift ;;
--attach) ATTACH=1; shift ;;
--full) FULL=1; shift ;;
--mesh) MESH=1; shift ;;
-*) die "unknown option: $1 (try ./run.sh --help)" ;;
*)
if [ -z "$COMMAND" ]; then COMMAND="$1"; else EXTRA="$EXTRA $1"; fi
shift ;;
esac
done
[ -n "$COMMAND" ] || { usage; exit 0; }
case "$COMMAND" in
doctor) cmd_doctor ;;
ports) cmd_ports ;;
env) cmd_env ;;
config) cmd_config ;;
up) cmd_up ;;
down) cmd_down ;;
status) cmd_status ;;
logs) cmd_logs $EXTRA ;;
migrate) cmd_migrate ;;
build) cmd_build ;;
relay) cmd_relay ;;
control-plane) cmd_control_plane ;;
desktop) cmd_desktop ;;
web) cmd_web ;;
admin) cmd_admin ;;
bundle) cmd_bundle ;;
all) cmd_all ;;
stop) cmd_stop ;;
qa) cmd_qa ;;
e2e) cmd_e2e ;;
reset) cmd_reset ;;
clean-legacy) cmd_clean_legacy ;;
help) usage ;;
*) die "unknown command: $COMMAND (try ./run.sh --help)" ;;
esac