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>
1844 lines
79 KiB
Bash
Executable file
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
|