Files
meshcore-analyzer/manage.sh
Kpa-clawbot d5b300a8ba fix: derive version from git tags instead of package.json (#486)
## Summary

Fixes #485 — the app version was derived from `package.json` via
Node.js, which is a meaningless artifact for this Go project. This
caused version mismatches (e.g., v3.3.0 release showing "3.2.0") when
someone forgot to bump `package.json`.

## Changes

### `manage.sh`
- **Line 43**: Replace `node -p "require('./package.json').version"`
with `git describe --tags --match "v*"` — version is now derived
automatically from git tags
- **Line 515**: Add `--force` to `git fetch origin --tags` in setup
command
- **Line 1320**: Add `--force` to `git fetch origin --tags` in update
command — prevents "would clobber existing tag" errors when tags are
moved

### `package.json`
- Version field set to `0.0.0-use-git-tags` to make it clear this is not
the source of truth. File kept because npm scripts and devDependencies
are still used for testing.

## How it works

`git describe --tags --match "v*"` produces:
- `v3.3.0` — when on an exact tag
- `v3.3.0-3-gabcdef1` — when 3 commits after a tag (useful for
debugging)
- Falls back to `unknown` if no tags exist

## Testing

- All Go tests pass (`cmd/server`, `cmd/ingestor`)
- All frontend unit tests pass (254/254)
- No changes to application logic — only build-time version derivation

Co-authored-by: you <you@example.com>
2026-04-02 00:53:38 -07:00

1600 lines
51 KiB
Bash
Executable File

#!/bin/bash
# CoreScope — Setup & Management Helper
# Usage: ./manage.sh [command]
#
# All container management goes through docker compose.
# Container config lives in docker-compose.yml — this script is just a wrapper.
#
# Idempotent: safe to cancel and re-run at any point.
# Each step checks what's already done and skips it.
set -e
IMAGE_NAME="corescope"
STATE_FILE=".setup-state"
STAGING_CONTAINER="corescope-staging-go"
# Source .env for port/path overrides (same file docker compose reads)
# Strip \r (Windows line endings) to avoid "$'\r': command not found"
if [ -f .env ]; then
set -a
while IFS='=' read -r key value || [ -n "$key" ]; do
key=$(printf '%s' "$key" | sed 's/\r$//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
value=$(printf '%s' "$value" | sed 's/\r$//' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
value="${value/#\~/$HOME}"
export "$key=$value"
done < .env
set +a
fi
# Auto-fix CRLF in .env if detected
if [ -f .env ] && grep -qP '\r' .env 2>/dev/null; then
warn ".env has Windows line endings (CRLF) — fixing automatically..."
sed -i 's/\r$//' .env
log ".env converted to Unix line endings."
fi
# Resolved paths for prod/staging data (must match docker-compose.yml)
PROD_DATA="${PROD_DATA_DIR:-$HOME/meshcore-data}"
STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
STAGING_COMPOSE_FILE="docker-compose.staging.yml"
# Build metadata — exported so docker compose build picks them up via args
export APP_VERSION=$(git describe --tags --match "v*" 2>/dev/null || echo "unknown")
export GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Docker Compose — detect v2 plugin vs v1 standalone
if docker compose version &>/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose &>/dev/null; then
DC="docker-compose"
else
echo "ERROR: Neither '$DC' nor 'docker-compose' found." >&2
exit 1
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { printf '%b\n' "${GREEN}${NC} $1"; }
warn() { printf '%b\n' "${YELLOW}${NC} $1"; }
err() { printf '%b\n' "${RED}${NC} $1"; }
info() { printf '%b\n' "${CYAN}${NC} $1"; }
step() { printf '%b\n' "\n${BOLD}[$1/$TOTAL_STEPS] $2${NC}"; }
is_true() {
case "${1:-}" in
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
*) return 1 ;;
esac
}
dc_prod() {
if is_true "${DISABLE_MOSQUITTO:-false}"; then
$DC -f docker-compose.no-mosquitto.yml "$@"
else
$DC "$@"
fi
}
dc_staging() {
if is_true "${DISABLE_MOSQUITTO:-false}"; then
$DC -f docker-compose.staging.no-mosquitto.yml -p corescope-staging "$@"
else
$DC -f "$STAGING_COMPOSE_FILE" -p corescope-staging "$@"
fi
}
confirm() {
read -p " $1 [y/N] " -n 1 -r
echo
[[ $REPLY =~ ^[Yy]$ ]]
}
confirm_yes_default() {
read -p " $1 [Y/n] " -n 1 -r
echo
[[ -z "$REPLY" || $REPLY =~ ^[Yy]$ ]]
}
# State tracking — marks completed steps so re-runs skip them
mark_done() { echo "$1" >> "$STATE_FILE"; }
is_done() { [ -f "$STATE_FILE" ] && grep -qx "$1" "$STATE_FILE" 2>/dev/null; }
# ─── Helpers ──────────────────────────────────────────────────────────────
resolve_domain_ipv4() {
local domain="$1"
local resolved_ip=""
if command -v dig >/dev/null 2>&1; then
resolved_ip=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -1)
fi
if [ -z "$resolved_ip" ] && command -v host >/dev/null 2>&1; then
resolved_ip=$(host "$domain" 2>/dev/null | awk '/has address/ {print $4; exit}')
fi
if [ -z "$resolved_ip" ] && command -v nslookup >/dev/null 2>&1; then
resolved_ip=$(nslookup "$domain" 2>/dev/null | awk '/^Address: / {print $2}' | grep -E '^[0-9]+\.' | head -1)
fi
if [ -z "$resolved_ip" ] && command -v getent >/dev/null 2>&1; then
resolved_ip=$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]+\.' | head -1)
fi
echo "$resolved_ip"
}
has_dns_resolution_tool() {
command -v dig >/dev/null 2>&1 || \
command -v host >/dev/null 2>&1 || \
command -v nslookup >/dev/null 2>&1 || \
command -v getent >/dev/null 2>&1
}
PORT_CHECK_METHOD=""
resolve_port_check_method() {
if [ -n "$PORT_CHECK_METHOD" ]; then
return 0
fi
if command -v ss &>/dev/null; then
PORT_CHECK_METHOD="ss"
elif command -v lsof &>/dev/null; then
PORT_CHECK_METHOD="lsof"
elif command -v netstat &>/dev/null; then
PORT_CHECK_METHOD="netstat"
elif command -v nc &>/dev/null; then
PORT_CHECK_METHOD="nc"
else
PORT_CHECK_METHOD="none"
fi
}
# Returns 0 when in use, 1 when free, 2 when unavailable
is_port_in_use() {
local port="$1"
resolve_port_check_method
case "$PORT_CHECK_METHOD" in
ss)
ss -tlnp 2>/dev/null | grep -E "[[:space:]]LISTEN[[:space:]].*[:.]${port}([[:space:]]|$)" >/dev/null
return $?
;;
lsof)
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
return $?
;;
netstat)
netstat -tlnp 2>/dev/null | grep -E "[[:space:]]${port}[[:space:]]" >/dev/null
if [ $? -eq 0 ]; then
return 0
fi
netstat -tlnp 2>/dev/null | grep -E "[:.]${port}[[:space:]]" >/dev/null
return $?
;;
nc)
local bind_pid=""
( nc -l 127.0.0.1 "$port" >/dev/null 2>&1 ) &
bind_pid=$!
sleep 0.2
if kill -0 "$bind_pid" 2>/dev/null; then
kill "$bind_pid" 2>/dev/null || true
wait "$bind_pid" 2>/dev/null || true
return 1
fi
wait "$bind_pid" 2>/dev/null || true
return 0
;;
*)
return 2
;;
esac
}
port_in_use_details() {
local port="$1"
resolve_port_check_method
case "$PORT_CHECK_METHOD" in
ss)
ss -tlnp 2>/dev/null | grep -E "[[:space:]]LISTEN[[:space:]].*[:.]${port}([[:space:]]|$)" | head -1
;;
lsof)
lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null | sed -n '2p'
;;
netstat)
netstat -tlnp 2>/dev/null | grep -E "[:.]${port}[[:space:]]" | head -1
;;
*)
echo ""
;;
esac
}
find_next_available_port() {
local start="$1"
local candidate=$((start + 1))
while [ "$candidate" -le 65535 ]; do
is_port_in_use "$candidate"
local rc=$?
if [ "$rc" -eq 0 ]; then
candidate=$((candidate + 1))
continue
fi
if [ "$rc" -eq 1 ]; then
echo "$candidate"
return 0
fi
break
done
echo ""
return 1
}
is_valid_port() {
local value="$1"
[[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge 1 ] && [ "$value" -le 65535 ]
}
show_env_port_summary() {
local http_port="$1"
local https_port="$2"
local mqtt_port="$3"
local data_dir="$4"
local disable_mosquitto="$5"
echo ""
echo " Current .env values:"
echo " PROD_HTTP_PORT=${http_port}"
echo " PROD_HTTPS_PORT=${https_port}"
echo " PROD_MQTT_PORT=${mqtt_port}"
echo " DISABLE_MOSQUITTO=${disable_mosquitto}"
echo " PROD_DATA_DIR=${data_dir}"
echo ""
}
get_env_value() {
local key="$1"
local env_file="${2:-.env}"
if [ ! -f "$env_file" ]; then
echo ""
return 1
fi
sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" "$env_file" | head -1
}
write_env_managed_values() {
local http_port="$1"
local https_port="$2"
local mqtt_port="$3"
local data_dir="$4"
local disable_mosquitto="$5"
local env_file=".env"
local tmp_file=".env.tmp.$$"
if [ ! -f "$env_file" ]; then
cp .env.example "$env_file"
fi
local seen_http=0
local seen_https=0
local seen_mqtt=0
local seen_data=0
local seen_disable_mosquitto=0
: > "$tmp_file"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
PROD_HTTP_PORT=*)
echo "PROD_HTTP_PORT=${http_port}" >> "$tmp_file"
seen_http=1
;;
PROD_HTTPS_PORT=*)
echo "PROD_HTTPS_PORT=${https_port}" >> "$tmp_file"
seen_https=1
;;
PROD_MQTT_PORT=*)
echo "PROD_MQTT_PORT=${mqtt_port}" >> "$tmp_file"
seen_mqtt=1
;;
PROD_DATA_DIR=*)
echo "PROD_DATA_DIR=${data_dir}" >> "$tmp_file"
seen_data=1
;;
DISABLE_MOSQUITTO=*)
echo "DISABLE_MOSQUITTO=${disable_mosquitto}" >> "$tmp_file"
seen_disable_mosquitto=1
;;
*)
echo "$line" >> "$tmp_file"
;;
esac
done < "$env_file"
[ "$seen_http" -eq 1 ] || echo "PROD_HTTP_PORT=${http_port}" >> "$tmp_file"
[ "$seen_https" -eq 1 ] || echo "PROD_HTTPS_PORT=${https_port}" >> "$tmp_file"
[ "$seen_mqtt" -eq 1 ] || echo "PROD_MQTT_PORT=${mqtt_port}" >> "$tmp_file"
[ "$seen_data" -eq 1 ] || echo "PROD_DATA_DIR=${data_dir}" >> "$tmp_file"
[ "$seen_disable_mosquitto" -eq 1 ] || echo "DISABLE_MOSQUITTO=${disable_mosquitto}" >> "$tmp_file"
mv "$tmp_file" "$env_file"
}
prompt_for_port() {
local label="$1"
local current="$2"
local prompt_default="$3"
while true; do
if [ -n "$prompt_default" ] && [ "$prompt_default" != "$current" ]; then
read -p " ${label} port [${prompt_default}] (current ${current}): " selected
selected=${selected:-$prompt_default}
else
read -p " ${label} port [${current}]: " selected
selected=${selected:-$current}
fi
if ! is_valid_port "$selected"; then
warn "Invalid port '${selected}'. Enter a value between 1 and 65535."
continue
fi
is_port_in_use "$selected"
local rc=$?
if [ "$rc" -eq 0 ]; then
warn "Port ${selected} is in use."
local details
details=$(port_in_use_details "$selected")
[ -n "$details" ] && echo " ${details}"
if confirm "Use ${selected} anyway? (start will fail if still occupied)"; then
echo "$selected"
return 0
fi
continue
fi
if [ "$rc" -eq 2 ]; then
warn "Port detection unavailable on this host. Proceeding with chosen value."
fi
echo "$selected"
return 0
done
}
preflight_validate_prod_ports() {
local http_port="${PROD_HTTP_PORT:-80}"
local https_port="${PROD_HTTPS_PORT:-443}"
local mqtt_port=""
if ! is_true "${DISABLE_MOSQUITTO:-false}"; then
mqtt_port="${PROD_MQTT_PORT:-1883}"
fi
local failed=0
info "Preflight: validating configured ports are free..."
local ports_to_check=("$http_port" "$https_port")
[ -n "$mqtt_port" ] && ports_to_check+=("$mqtt_port")
for port in "${ports_to_check[@]}"; do
if is_port_in_use "$port"; then
err "Port ${port} is in use."
local details
details=$(port_in_use_details "$port")
[ -n "$details" ] && echo " ${details}"
failed=1
fi
done
if [ "$failed" -eq 1 ]; then
echo ""
echo " Remediation:"
echo " • Stop the process using the conflicting port(s)"
echo " • Or run ./manage.sh setup and re-negotiate ports"
echo " • Then re-run this command"
return 1
fi
log "Preflight port validation passed."
return 0
}
# Check config.json for placeholder values
check_config_placeholders() {
local cfg="${1:-$PROD_DATA/config.json}"
if [ -f "$cfg" ]; then
if grep -qE 'your-username|your-password|your-secret|example\.com|changeme' "$cfg" 2>/dev/null; then
warn "config.json contains placeholder values."
warn "Edit ${cfg} and replace placeholder values before deploying."
fi
fi
}
# Verify the running container is actually healthy
verify_health() {
local container="corescope-prod"
local use_https=false
# Check if Caddyfile has a real domain (not :80)
if [ -f caddy-config/Caddyfile ]; then
local caddyfile_domain
caddyfile_domain=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
if [ "$caddyfile_domain" != ":80" ] && [ -n "$caddyfile_domain" ]; then
use_https=true
fi
fi
# Wait for /api/stats response (Go backend loads packets into memory — may take 60s+)
info "Waiting for server to respond..."
local healthy=false
for i in $(seq 1 45); do
if docker exec "$container" wget -qO- http://localhost:3000/api/stats &>/dev/null; then
healthy=true
break
fi
sleep 2
done
if ! $healthy; then
err "Server did not respond after 90 seconds."
warn "Check logs: ./manage.sh logs"
return 1
fi
log "Server is responding."
# Check for MQTT errors in recent logs
local mqtt_errors
mqtt_errors=$(docker logs "$container" --tail 50 2>&1 | grep -i 'mqtt.*error\|mqtt.*fail\|ECONNREFUSED.*1883' || true)
if [ -n "$mqtt_errors" ]; then
warn "MQTT errors detected in logs:"
echo "$mqtt_errors" | head -5 | sed 's/^/ /'
fi
# If HTTPS domain configured, try to verify externally
if $use_https; then
info "Checking HTTPS for ${caddyfile_domain}..."
if command -v curl &>/dev/null; then
if curl -sf --connect-timeout 5 "https://${caddyfile_domain}/api/stats" &>/dev/null; then
log "HTTPS is working: https://${caddyfile_domain}"
else
warn "HTTPS not reachable yet for ${caddyfile_domain}"
warn "It may take a minute for Caddy to provision the certificate."
fi
fi
fi
return 0
}
# ─── Setup Wizard ─────────────────────────────────────────────────────────
TOTAL_STEPS=6
cmd_setup() {
echo ""
echo "═══════════════════════════════════════"
echo " CoreScope Setup"
echo "═══════════════════════════════════════"
echo ""
if [ -f "$STATE_FILE" ]; then
info "Resuming previous setup. Delete ${STATE_FILE} to start over."
echo ""
fi
# ── Step 1: Check Docker ──
step 1 "Checking Docker"
if ! command -v docker &> /dev/null; then
err "Docker is not installed."
echo ""
echo " Install it:"
echo " curl -fsSL https://get.docker.com | sh"
echo " sudo usermod -aG docker \$USER"
echo ""
echo " Then log out, log back in, and run ./manage.sh setup again."
exit 1
fi
# Check if user can actually run Docker
if ! docker info &> /dev/null; then
err "Docker is installed but your user can't run it."
echo ""
echo " Fix: sudo usermod -aG docker \$USER"
echo " Then log out, log back in, and try again."
exit 1
fi
log "Docker $(docker --version | grep -oP 'version \K[^ ,]+')"
log "Compose: $DC"
# Default to latest release tag (instead of staying on master)
if ! is_done "version_pin"; then
git fetch origin --tags --force 2>/dev/null || true
local latest_tag
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
if [ -n "$latest_tag" ]; then
local current_ref
current_ref=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ "$current_ref" != "$latest_tag" ]; then
info "Pinning to latest release: ${latest_tag}"
git checkout "$latest_tag" 2>/dev/null
else
log "Already on latest release: ${latest_tag}"
fi
fi
mark_done "version_pin"
fi
mark_done "docker"
# ── Step 2: Config ──
step 2 "Configuration"
if [ -f "$PROD_DATA/config.json" ]; then
log "config.json found in data directory."
# Sanity check the JSON
if ! python3 -c "import json; json.load(open('$PROD_DATA/config.json'))" 2>/dev/null && \
! node -e "JSON.parse(require('fs').readFileSync('$PROD_DATA/config.json'))" 2>/dev/null; then
err "config.json has invalid JSON. Fix it and re-run setup."
exit 1
fi
log "config.json is valid JSON."
check_config_placeholders "$PROD_DATA/config.json"
elif [ -f config.json ]; then
# Legacy: config in repo root — move it to data dir
info "Found config.json in repo root — moving to data directory..."
mkdir -p "$PROD_DATA"
cp config.json "$PROD_DATA/config.json"
log "Config moved to ${PROD_DATA}/config.json"
check_config_placeholders "$PROD_DATA/config.json"
else
info "Creating config.json in data directory from example..."
mkdir -p "$PROD_DATA"
cp config.example.json "$PROD_DATA/config.json"
# Generate a random API key
if command -v openssl &> /dev/null; then
API_KEY=$(openssl rand -hex 16)
else
API_KEY=$(head -c 32 /dev/urandom | xxd -p | head -c 32)
fi
# Replace the placeholder API key
if command -v sed &> /dev/null; then
sed -i "s/your-secret-api-key-here/${API_KEY}/" "$PROD_DATA/config.json"
fi
log "Created config.json with random API key."
check_config_placeholders "$PROD_DATA/config.json"
echo ""
echo " Config saved to: ${PROD_DATA}/config.json"
echo " Edit with: nano ${PROD_DATA}/config.json"
echo ""
fi
mark_done "config"
# ── Step 3: Ports & Networking ──
step 3 "Ports & Networking"
local default_http=80
local default_https=443
local default_mqtt=1883
local selected_http="$default_http"
local selected_https="$default_https"
local selected_mqtt="$default_mqtt"
local selected_disable_mosquitto="${DISABLE_MOSQUITTO:-false}"
local selected_data_dir="${PROD_DATA_DIR:-$HOME/meshcore-data}"
local env_http=""
local env_https=""
local env_mqtt=""
local env_disable_mosquitto=""
local env_data_dir=""
if [ -f .env ]; then
env_http=$(get_env_value "PROD_HTTP_PORT" ".env")
env_https=$(get_env_value "PROD_HTTPS_PORT" ".env")
env_mqtt=$(get_env_value "PROD_MQTT_PORT" ".env")
env_disable_mosquitto=$(get_env_value "DISABLE_MOSQUITTO" ".env")
env_data_dir=$(get_env_value "PROD_DATA_DIR" ".env")
env_data_dir="${env_data_dir/#\~/$HOME}"
[ -n "$env_data_dir" ] && selected_data_dir="$env_data_dir"
[ -n "$env_disable_mosquitto" ] && selected_disable_mosquitto="$env_disable_mosquitto"
show_env_port_summary "${env_http:-<unset>}" "${env_https:-<unset>}" "${env_mqtt:-<unset>}" "${env_data_dir:-<unset>}" "${env_disable_mosquitto:-<unset>}"
else
info ".env not found. It will be created from .env.example."
fi
local has_current_ports=false
if is_true "$selected_disable_mosquitto"; then
if is_valid_port "$env_http" && is_valid_port "$env_https"; then
has_current_ports=true
fi
elif is_valid_port "$env_http" && is_valid_port "$env_https" && is_valid_port "$env_mqtt"; then
has_current_ports=true
fi
local renegotiate=true
if [ -f .env ] && $has_current_ports; then
if confirm "Keep current ports from .env?"; then
renegotiate=false
selected_http="$env_http"
selected_https="$env_https"
if is_valid_port "$env_mqtt"; then
selected_mqtt="$env_mqtt"
fi
log "Keeping current ports from .env."
fi
fi
if $renegotiate; then
resolve_port_check_method
if [ "$PORT_CHECK_METHOD" = "none" ]; then
warn "No supported port detection tool found (ss/lsof/netstat/nc)."
warn "You'll still be prompted, but conflicts cannot be detected now."
else
info "Detecting listeners using ${PORT_CHECK_METHOD}..."
fi
local suggested_http="$default_http"
local suggested_https="$default_https"
local suggested_mqtt="$default_mqtt"
if is_port_in_use "$default_http"; then
warn "Port ${default_http} is in use."
local details_http
details_http=$(port_in_use_details "$default_http")
[ -n "$details_http" ] && echo " ${details_http}"
suggested_http=$(find_next_available_port "$default_http")
[ -n "$suggested_http" ] && info "Suggested HTTP port: ${suggested_http}"
fi
if is_port_in_use "$default_https"; then
warn "Port ${default_https} is in use."
local details_https
details_https=$(port_in_use_details "$default_https")
[ -n "$details_https" ] && echo " ${details_https}"
suggested_https=$(find_next_available_port "$default_https")
[ -n "$suggested_https" ] && info "Suggested HTTPS port: ${suggested_https}"
fi
selected_http=$(prompt_for_port "HTTP" "$default_http" "$suggested_http")
selected_https=$(prompt_for_port "HTTPS" "$default_https" "$suggested_https")
if confirm_yes_default "Use built-in MQTT broker?"; then
selected_disable_mosquitto="false"
if is_port_in_use "$default_mqtt"; then
warn "Port ${default_mqtt} is in use."
local details_mqtt
details_mqtt=$(port_in_use_details "$default_mqtt")
[ -n "$details_mqtt" ] && echo " ${details_mqtt}"
suggested_mqtt=$(find_next_available_port "$default_mqtt")
[ -n "$suggested_mqtt" ] && info "Suggested MQTT port: ${suggested_mqtt}"
fi
selected_mqtt=$(prompt_for_port "MQTT" "$default_mqtt" "$suggested_mqtt")
else
selected_disable_mosquitto="true"
log "Internal MQTT broker disabled."
fi
fi
if [ -f caddy-config/Caddyfile ]; then
EXISTING_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
if [ "$EXISTING_DOMAIN" = ":80" ] || [ "$EXISTING_DOMAIN" = ":${selected_http}" ]; then
log "Caddyfile exists (HTTP only, no HTTPS)."
else
log "Caddyfile exists for ${EXISTING_DOMAIN}"
fi
else
mkdir -p caddy-config
echo ""
echo " How should the analyzer be accessed?"
echo ""
echo " 1) Direct with built-in HTTPS — Caddy auto-provisions a TLS cert"
echo " (requires ports 80 + 443 open, and a domain pointed at this server)"
echo ""
echo " 2) Behind my own reverse proxy — HTTP only"
echo " (for Cloudflare Tunnel, nginx, Traefik, etc.)"
echo ""
read -p " Choose [1/2]: " -n 1 -r
echo ""
case $REPLY in
1)
read -p " Enter your domain (e.g., analyzer.example.com): " DOMAIN
if [ -z "$DOMAIN" ]; then
err "No domain entered. Re-run setup to try again."
exit 1
fi
echo "${DOMAIN} {
reverse_proxy localhost:3000
}" > caddy-config/Caddyfile
log "Caddyfile created for ${DOMAIN}"
# Validate DNS
info "Checking DNS..."
RESOLVED_IP=$(resolve_domain_ipv4 "$DOMAIN")
MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "unknown")
if [ -z "$RESOLVED_IP" ]; then
if has_dns_resolution_tool; then
warn "${DOMAIN} doesn't resolve yet."
warn "Create an A record pointing to ${MY_IP}"
warn "HTTPS won't work until DNS propagates (1-60 min)."
else
warn "DNS tool not found; skipping domain resolution check."
fi
echo ""
if ! confirm "Continue anyway?"; then
echo " Run ./manage.sh setup again when DNS is ready."
exit 0
fi
elif [ "$RESOLVED_IP" = "$MY_IP" ]; then
log "DNS resolves correctly: ${DOMAIN}${MY_IP}"
else
warn "${DOMAIN} resolves to ${RESOLVED_IP} but this server is ${MY_IP}"
warn "HTTPS provisioning will fail if the domain doesn't point here."
if ! confirm "Continue anyway?"; then
echo " Fix DNS and run ./manage.sh setup again."
exit 0
fi
fi
;;
2)
echo ":${selected_http} {
reverse_proxy localhost:3000
}" > caddy-config/Caddyfile
log "Caddyfile created (HTTP only on port ${selected_http})."
echo " Point your reverse proxy or tunnel to this server's port ${selected_http}."
;;
*)
warn "Invalid choice. Defaulting to HTTP only."
echo ":${selected_http} {
reverse_proxy localhost:3000
}" > caddy-config/Caddyfile
;;
esac
fi
write_env_managed_values "$selected_http" "$selected_https" "$selected_mqtt" "$selected_data_dir" "$selected_disable_mosquitto"
log "Saved negotiated ports to .env"
show_env_port_summary "$selected_http" "$selected_https" "$selected_mqtt" "$selected_data_dir" "$selected_disable_mosquitto"
echo " Resolved port mapping:"
echo " UI HTTP: ${selected_http}"
echo " UI HTTPS: ${selected_https}"
if is_true "$selected_disable_mosquitto"; then
echo " MQTT: disabled (external broker)"
else
echo " MQTT: ${selected_mqtt}"
fi
echo ""
if ! confirm "Proceed to build/start with these ports?"; then
echo " Setup cancelled. Re-run ./manage.sh setup when ready."
exit 0
fi
export PROD_HTTP_PORT="$selected_http"
export PROD_HTTPS_PORT="$selected_https"
export PROD_MQTT_PORT="$selected_mqtt"
export DISABLE_MOSQUITTO="$selected_disable_mosquitto"
export PROD_DATA_DIR="$selected_data_dir"
PROD_DATA="$PROD_DATA_DIR"
mark_done "caddyfile"
# ── Step 4: Build ──
step 4 "Building Docker image"
# Check if image exists and source hasn't changed
IMAGE_EXISTS=$(docker images -q "$IMAGE_NAME" 2>/dev/null)
if [ -n "$IMAGE_EXISTS" ] && is_done "build"; then
log "Image already built."
if confirm "Rebuild? (only needed if you updated the code)"; then
dc_prod build prod
log "Image rebuilt."
fi
else
info "This takes 1-2 minutes the first time..."
dc_prod build prod
log "Image built."
fi
mark_done "build"
# ── Step 5: Start container ──
step 5 "Starting container"
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
info "Production container already running — skipping preflight port check."
else
if ! preflight_validate_prod_ports; then
exit 1
fi
fi
# Detect existing data directories
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
info "Found existing data at $PROD_DATA/ — will use bind mount."
fi
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
log "Container already running."
else
mkdir -p "$PROD_DATA"
dc_prod up -d prod
log "Container started."
fi
mark_done "container"
# ── Step 6: Verify ──
step 6 "Verifying"
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
verify_health
CADDYFILE_DOMAIN=$(grep -v '^#' caddy-config/Caddyfile 2>/dev/null | head -1 | tr -d ' {')
echo ""
echo "═══════════════════════════════════════"
echo " Setup complete!"
echo "═══════════════════════════════════════"
echo ""
if [ "$CADDYFILE_DOMAIN" != ":80" ] && [ -n "$CADDYFILE_DOMAIN" ]; then
echo " 🌐 https://${CADDYFILE_DOMAIN}"
else
MY_IP=$(curl -s -4 ifconfig.me 2>/dev/null || echo "your-server-ip")
echo " 🌐 http://${MY_IP}"
fi
echo ""
echo " Next steps:"
echo " • Connect an observer to start receiving packets"
echo " • Customize branding in config.json"
echo " • Set up backups: ./manage.sh backup"
echo ""
echo " Useful commands:"
echo " ./manage.sh status Check health"
echo " ./manage.sh logs View logs"
echo " ./manage.sh backup Full backup (DB + config + theme)"
echo " ./manage.sh update Update to latest version"
echo ""
else
err "Container failed to start."
echo ""
echo " Check what went wrong:"
echo " $DC logs prod"
echo ""
echo " Common fixes:"
echo " • Invalid config.json — check JSON syntax"
echo " • Port conflict — stop other web servers"
echo " • Re-run: ./manage.sh setup"
echo ""
exit 1
fi
mark_done "verify"
}
# ─── Staging Helpers ──────────────────────────────────────────────────────
# Copy production DB to staging data directory
prepare_staging_db() {
mkdir -p "$STAGING_DATA"
if [ -f "$PROD_DATA/meshcore.db" ]; then
info "Copying production database to staging..."
cp "$PROD_DATA/meshcore.db" "$STAGING_DATA/meshcore.db" 2>/dev/null || true
log "Database snapshot copied to ${STAGING_DATA}/meshcore.db"
else
warn "No production database found at ${PROD_DATA}/meshcore.db — staging starts empty."
fi
}
# Copy config.prod.json → config.staging.json with siteName change
prepare_staging_config() {
local prod_config="$PROD_DATA/config.json"
local staging_config="$STAGING_DATA/config.json"
mkdir -p "$STAGING_DATA"
# Docker may have created config.json as a directory
[ -d "$staging_config" ] && rmdir "$staging_config" 2>/dev/null || true
if [ ! -f "$prod_config" ]; then
warn "No production config at ${prod_config} — staging may use defaults."
return
fi
info "Copying production config to staging..."
cp "$prod_config" "$staging_config"
sed -i 's/"siteName":\s*"[^"]*"/"siteName": "CoreScope — STAGING"/' "$staging_config"
log "Staging config created at ${staging_config} with STAGING site name."
# Copy Caddyfile for staging (HTTP-only on staging port)
local staging_caddy="$STAGING_DATA/Caddyfile"
if [ ! -f "$staging_caddy" ]; then
info "Creating staging Caddyfile (HTTP-only on port ${STAGING_GO_HTTP_PORT:-82})..."
echo ":${STAGING_GO_HTTP_PORT:-82} {" > "$staging_caddy"
echo " reverse_proxy localhost:3000" >> "$staging_caddy"
echo "}" >> "$staging_caddy"
log "Staging Caddyfile created at ${staging_caddy}"
fi
}
# Check if a container is running by name
container_running() {
docker ps --format '{{.Names}}' | grep -q "^${1}$"
}
# Get health status of a container
container_health() {
docker inspect "$1" --format '{{.State.Health.Status}}' 2>/dev/null || echo "unknown"
}
# ─── Start / Stop / Restart ──────────────────────────────────────────────
# Migrate legacy root config.json to data directory path
migrate_config() {
local mode="${1:-interactive}"
local legacy_config="./config.json"
local target_config="$PROD_DATA/config.json"
local reply=""
mkdir -p "$PROD_DATA"
if [ -f "$target_config" ]; then
return 0
fi
if [ ! -f "$legacy_config" ]; then
return 0
fi
if [ "$mode" = "auto" ]; then
echo "→ Migrating config.json from repo root to ${PROD_DATA}/config.json..."
cp "$legacy_config" "$target_config"
echo "✓ Config migrated."
return 0
fi
echo ""
echo "Found legacy config location:"
echo " Source: ./config.json (repo root)"
echo " Destination: ${PROD_DATA}/config.json"
echo " Note: CoreScope reads config from the data directory at runtime."
read -p "Move to ${PROD_DATA}/config.json? [Y/n] " reply
if [ -z "$reply" ] || [[ "$reply" =~ ^[Yy]$ ]]; then
cp "$legacy_config" "$target_config"
log "Copied config.json to ${target_config}"
return 0
fi
warn "Migration aborted."
echo "Move ./config.json to ${PROD_DATA}/config.json, then run this command again."
return 1
}
# Ensure config.json exists in the data directory before starting
ensure_config() {
local data_dir="$1"
local config="$data_dir/config.json"
mkdir -p "$data_dir"
# Docker may have created config.json as a directory from a prior failed mount
[ -d "$config" ] && rmdir "$config" 2>/dev/null || true
if [ -f "$config" ]; then
return 0
fi
# Try to copy from repo root (legacy location)
if [ -f ./config.json ]; then
info "No config in data directory — copying from ./config.json"
cp ./config.json "$config"
return 0
fi
# Prompt admin
echo ""
warn "No config.json found in ${data_dir}/"
echo ""
echo " CoreScope needs a config.json to connect to MQTT brokers."
echo ""
echo " Options:"
echo " 1) Create from example (you'll edit MQTT settings after)"
echo " 2) I'll put one there myself (abort for now)"
echo ""
read -p " Choose [1/2]: " -n 1 -r
echo ""
case $REPLY in
1)
cp config.example.json "$config"
# Generate a random API key
if command -v openssl &>/dev/null; then
API_KEY=$(openssl rand -hex 16)
else
API_KEY=$(head -c 32 /dev/urandom | xxd -p | head -c 32)
fi
sed -i "s/your-secret-api-key-here/${API_KEY}/" "$config" 2>/dev/null || true
log "Created ${config} from example with random API key."
warn "Edit MQTT settings before connecting observers:"
echo " nano ${config}"
echo ""
;;
*)
echo " Place your config.json at: ${config}"
echo " Then run this command again."
exit 0
;;
esac
}
cmd_start() {
local WITH_STAGING=false
if [ "$1" = "--with-staging" ]; then
WITH_STAGING=true
fi
if docker ps --format '{{.Names}}' | grep -q "^corescope-prod$"; then
info "Production container already running — skipping preflight port check."
else
if ! preflight_validate_prod_ports; then
exit 1
fi
fi
migrate_config || exit 1
# Always check prod config
ensure_config "$PROD_DATA"
if $WITH_STAGING; then
# Prepare staging data and config
prepare_staging_db
prepare_staging_config
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
info "Starting staging container (${STAGING_CONTAINER}) on port ${STAGING_GO_HTTP_PORT:-82}..."
dc_prod up -d prod
dc_staging up -d staging-go
if is_true "${DISABLE_MOSQUITTO:-false}"; then
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443} (MQTT disabled)"
log "Staging started on port ${STAGING_GO_HTTP_PORT:-82} (MQTT disabled)"
else
log "Production started on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}/${PROD_MQTT_PORT:-1883}"
log "Staging started on port ${STAGING_GO_HTTP_PORT:-82} (MQTT: ${STAGING_GO_MQTT_PORT:-1885})"
fi
else
info "Starting production container (corescope-prod) on ports ${PROD_HTTP_PORT:-80}/${PROD_HTTPS_PORT:-443}..."
dc_prod up -d prod
log "Production started. Staging NOT running (use --with-staging to start both)."
fi
}
cmd_stop() {
local TARGET="${1:-all}"
case "$TARGET" in
prod)
info "Stopping production container (corescope-prod)..."
dc_prod stop prod
log "Production stopped."
;;
staging)
info "Stopping staging container (${STAGING_CONTAINER})..."
dc_staging rm -sf staging-go 2>/dev/null || true
docker rm -f "$STAGING_CONTAINER" meshcore-staging-go corescope-staging meshcore-staging 2>/dev/null || true
log "Staging stopped and cleaned up."
;;
all)
info "Stopping all containers..."
dc_prod stop prod
dc_staging rm -sf staging-go 2>/dev/null || true
docker rm -f "$STAGING_CONTAINER" meshcore-staging-go corescope-staging meshcore-staging 2>/dev/null || true
log "All containers stopped."
;;
*)
err "Usage: ./manage.sh stop [prod|staging|all]"
exit 1
;;
esac
}
cmd_restart() {
local TARGET="${1:-prod}"
case "$TARGET" in
prod)
info "Restarting production container (corescope-prod)..."
dc_prod up -d --force-recreate prod
log "Production restarted."
;;
staging)
info "Restarting staging container (${STAGING_CONTAINER})..."
# Stop and remove old container
dc_staging rm -sf staging-go 2>/dev/null || true
docker rm -f "$STAGING_CONTAINER" 2>/dev/null || true
# Wait for container to be fully gone and memory to be reclaimed
# This prevents OOM when old + new containers overlap on small VMs
for i in $(seq 1 15); do
if ! docker ps -a --format '{{.Names}}' | grep -q "$STAGING_CONTAINER"; then
break
fi
sleep 1
done
sleep 3 # extra pause for OS to reclaim memory
# Verify config exists before starting
local staging_config="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}/config.json"
if [ ! -f "$staging_config" ]; then
warn "Staging config not found at $staging_config — creating from prod config..."
prepare_staging_config
fi
dc_staging up -d staging-go
log "Staging restarted."
;;
all)
info "Restarting all containers..."
dc_prod up -d --force-recreate prod
dc_staging rm -sf staging-go 2>/dev/null || true
docker rm -f "$STAGING_CONTAINER" 2>/dev/null || true
dc_staging up -d staging-go
log "All containers restarted."
;;
*)
err "Usage: ./manage.sh restart [prod|staging|all]"
exit 1
;;
esac
}
# ─── Status ───────────────────────────────────────────────────────────────
# Show status for a single container (used in compose mode)
show_container_status() {
local NAME="$1"
local LABEL="$2"
if container_running "$NAME"; then
local health
health=$(container_health "$NAME")
log "${LABEL} (${NAME}): Running — Health: ${health}"
docker ps --filter "name=${NAME}" --format " Ports: {{.Ports}}"
# Server stats
if docker exec "$NAME" wget -qO /dev/null http://localhost:3000/api/stats 2>/dev/null; then
local stats packets nodes
stats=$(docker exec "$NAME" wget -qO- http://localhost:3000/api/stats 2>/dev/null)
packets=$(echo "$stats" | grep -oP '"totalPackets":\K[0-9]+' 2>/dev/null || echo "?")
nodes=$(echo "$stats" | grep -oP '"totalNodes":\K[0-9]+' 2>/dev/null || echo "?")
info " ${packets} packets, ${nodes} nodes"
fi
else
if docker ps -a --format '{{.Names}}' | grep -q "^${NAME}$"; then
warn "${LABEL} (${NAME}): Stopped"
else
info "${LABEL} (${NAME}): Not running"
fi
fi
}
cmd_status() {
echo ""
echo "═══════════════════════════════════════"
echo " CoreScope Status"
echo "═══════════════════════════════════════"
echo ""
# Version
local current_version
current_version=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "unknown")
info "Version: ${current_version}"
echo ""
# Production
show_container_status "corescope-prod" "Production"
echo ""
# Staging
if container_running "$STAGING_CONTAINER"; then
show_container_status "$STAGING_CONTAINER" "Staging"
else
info "Staging (${STAGING_CONTAINER}): Not running (use --with-staging to start both)"
fi
echo ""
# Disk usage
if [ -d "$PROD_DATA" ] && [ -f "$PROD_DATA/meshcore.db" ]; then
local db_size
db_size=$(du -h "$PROD_DATA/meshcore.db" 2>/dev/null | cut -f1)
info "Production DB: ${db_size}"
fi
if [ -d "$STAGING_DATA" ] && [ -f "$STAGING_DATA/meshcore.db" ]; then
local staging_db_size
staging_db_size=$(du -h "$STAGING_DATA/meshcore.db" 2>/dev/null | cut -f1)
info "Staging DB: ${staging_db_size}"
fi
echo ""
}
# ─── Logs ─────────────────────────────────────────────────────────────────
cmd_logs() {
local TARGET="${1:-prod}"
local LINES="${2:-100}"
case "$TARGET" in
prod)
info "Tailing production logs..."
dc_prod logs -f --tail="$LINES" prod
;;
staging)
if container_running "$STAGING_CONTAINER"; then
info "Tailing staging logs..."
dc_staging logs -f --tail="$LINES" staging-go
else
err "Staging container is not running."
info "Start with: ./manage.sh start --with-staging"
exit 1
fi
;;
*)
err "Usage: ./manage.sh logs [prod|staging] [lines]"
exit 1
;;
esac
}
# ─── Promote ──────────────────────────────────────────────────────────────
cmd_promote() {
echo ""
info "Promotion Flow: Staging → Production"
echo ""
echo "This will:"
echo " 1. Backup current production database"
echo " 2. Restart production with latest image (same as staging)"
echo " 3. Wait for health check"
echo ""
# Show what's currently running
local staging_image staging_created prod_image prod_created
staging_image=$(docker inspect "$STAGING_CONTAINER" --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
staging_created=$(docker inspect "$STAGING_CONTAINER" --format '{{.Created}}' 2>/dev/null || echo "N/A")
prod_image=$(docker inspect corescope-prod --format '{{.Config.Image}}' 2>/dev/null || echo "not running")
prod_created=$(docker inspect corescope-prod --format '{{.Created}}' 2>/dev/null || echo "N/A")
echo " Staging: ${staging_image} (created ${staging_created})"
echo " Prod: ${prod_image} (created ${prod_created})"
echo ""
if ! confirm "Proceed with promotion?"; then
echo " Aborted."
exit 0
fi
# Backup production DB
info "Backing up production database..."
local BACKUP_DIR="./backups/pre-promotion-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
if [ -f "$PROD_DATA/meshcore.db" ]; then
cp "$PROD_DATA/meshcore.db" "$BACKUP_DIR/"
elif container_running "corescope-prod"; then
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/"
else
warn "Could not backup production database."
fi
log "Backup saved to ${BACKUP_DIR}/"
# Restart prod with latest image
info "Restarting production with latest image..."
dc_prod up -d --force-recreate prod
# Wait for health
info "Waiting for production health check..."
local i health
for i in $(seq 1 30); do
health=$(container_health "corescope-prod")
if [ "$health" = "healthy" ]; then
log "Production healthy after ${i}s"
break
fi
if [ "$i" -eq 30 ]; then
err "Production failed health check after 30s"
warn "Check logs: ./manage.sh logs prod"
warn "Rollback: cp ${BACKUP_DIR}/meshcore.db ${PROD_DATA}/ && ./manage.sh restart prod"
exit 1
fi
sleep 1
done
log "Promotion complete ✓"
echo ""
echo " Production is now running the same image as staging."
echo " Backup: ${BACKUP_DIR}/"
echo ""
}
# ─── Update ───────────────────────────────────────────────────────────────
cmd_update() {
local version="${1:-}"
info "Fetching latest changes and tags..."
git fetch origin --tags --force
if [ -z "$version" ]; then
# No arg: checkout latest release tag
local latest_tag
latest_tag=$(git tag -l 'v*' --sort=-v:refname | head -1)
if [ -z "$latest_tag" ]; then
err "No release tags found. Use './manage.sh update latest' for tip of master."
exit 1
fi
info "Checking out latest release: ${latest_tag}"
git checkout "$latest_tag" || { err "Failed to checkout tag '${latest_tag}'."; exit 1; }
elif [ "$version" = "latest" ]; then
# Explicit opt-in to bleeding edge (tip of master)
# Note: this creates a detached HEAD at origin/master, which is intentional —
# we want a read-only snapshot of upstream, not a local tracking branch.
info "Checking out tip of master (detached HEAD at origin/master)..."
git checkout origin/master || { err "Failed to checkout origin/master."; exit 1; }
else
# Specific tag requested
if ! git tag -l "$version" | grep -q .; then
err "Tag '${version}' not found."
echo ""
echo " Available releases:"
git tag -l 'v*' --sort=-v:refname | head -10 | sed 's/^/ /'
exit 1
fi
info "Checking out version: ${version}"
git checkout "$version" || { err "Failed to checkout '${version}'."; exit 1; }
fi
migrate_config auto
info "Rebuilding image..."
dc_prod build prod
info "Restarting with new image..."
dc_prod up -d --force-recreate prod
log "Updated and restarted. Data preserved."
# Show current version
local current
current=$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)
log "Running version: ${current}"
}
# ─── Backup ───────────────────────────────────────────────────────────────
cmd_backup() {
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="${1:-./backups/corescope-${TIMESTAMP}}"
mkdir -p "$BACKUP_DIR"
info "Backing up to ${BACKUP_DIR}/"
# Database
# Always use bind mount path (from .env or default)
DB_PATH="$PROD_DATA/meshcore.db"
if [ -f "$DB_PATH" ]; then
cp "$DB_PATH" "$BACKUP_DIR/meshcore.db"
log "Database ($(du -h "$BACKUP_DIR/meshcore.db" | cut -f1))"
elif container_running "corescope-prod"; then
docker cp corescope-prod:/app/data/meshcore.db "$BACKUP_DIR/meshcore.db" 2>/dev/null && \
log "Database (via docker cp)" || warn "Could not backup database"
else
warn "Database not found (container not running?)"
fi
# Config (now lives in data dir)
if [ -f "$PROD_DATA/config.json" ]; then
cp "$PROD_DATA/config.json" "$BACKUP_DIR/config.json"
log "config.json"
elif [ -f config.json ]; then
cp config.json "$BACKUP_DIR/config.json"
log "config.json (legacy repo root)"
fi
# Caddyfile
if [ -f caddy-config/Caddyfile ]; then
cp caddy-config/Caddyfile "$BACKUP_DIR/Caddyfile"
log "Caddyfile"
fi
# Theme
# Always use bind mount path (from .env or default)
THEME_PATH="$PROD_DATA/theme.json"
if [ -f "$THEME_PATH" ]; then
cp "$THEME_PATH" "$BACKUP_DIR/theme.json"
log "theme.json"
elif [ -f theme.json ]; then
cp theme.json "$BACKUP_DIR/theme.json"
log "theme.json"
fi
# Summary
TOTAL=$(du -sh "$BACKUP_DIR" | cut -f1)
FILES=$(ls "$BACKUP_DIR" | wc -l)
echo ""
log "Backup complete: ${FILES} files, ${TOTAL} total → ${BACKUP_DIR}/"
}
# ─── Restore ──────────────────────────────────────────────────────────────
cmd_restore() {
if [ -z "$1" ]; then
err "Usage: ./manage.sh restore <backup-dir-or-db-file>"
if [ -d "./backups" ]; then
echo ""
echo " Available backups:"
ls -dt ./backups/meshcore-* ./backups/corescope-* 2>/dev/null | head -10 | while read d; do
if [ -d "$d" ]; then
echo " $d/ ($(ls "$d" | wc -l) files)"
elif [ -f "$d" ]; then
echo " $d ($(du -h "$d" | cut -f1))"
fi
done
fi
exit 1
fi
# Accept either a directory (full backup) or a single .db file
if [ -d "$1" ]; then
DB_FILE="$1/meshcore.db"
CONFIG_FILE="$1/config.json"
CADDY_FILE="$1/Caddyfile"
THEME_FILE="$1/theme.json"
elif [ -f "$1" ]; then
DB_FILE="$1"
CONFIG_FILE=""
CADDY_FILE=""
THEME_FILE=""
else
err "Not found: $1"
exit 1
fi
if [ ! -f "$DB_FILE" ]; then
err "No meshcore.db found in $1"
exit 1
fi
echo ""
info "Will restore from: $1"
[ -f "$DB_FILE" ] && echo " • Database"
[ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ] && echo " • config.json"
[ -n "$CADDY_FILE" ] && [ -f "$CADDY_FILE" ] && echo " • Caddyfile"
[ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ] && echo " • theme.json"
echo ""
if ! confirm "Continue? (current state will be backed up first)"; then
echo " Aborted."
exit 0
fi
# Backup current state first
info "Backing up current state..."
cmd_backup "./backups/corescope-pre-restore-$(date +%Y%m%d-%H%M%S)"
dc_prod stop prod 2>/dev/null || true
# Restore database
mkdir -p "$PROD_DATA"
DEST_DB="$PROD_DATA/meshcore.db"
cp "$DB_FILE" "$DEST_DB"
log "Database restored"
# Restore config if present
if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
cp "$CONFIG_FILE" "$PROD_DATA/config.json"
log "config.json restored to ${PROD_DATA}/"
fi
# Restore Caddyfile if present
if [ -n "$CADDY_FILE" ] && [ -f "$CADDY_FILE" ]; then
mkdir -p caddy-config
cp "$CADDY_FILE" caddy-config/Caddyfile
log "Caddyfile restored"
fi
# Restore theme if present
if [ -n "$THEME_FILE" ] && [ -f "$THEME_FILE" ]; then
DEST_THEME="$PROD_DATA/theme.json"
cp "$THEME_FILE" "$DEST_THEME"
log "theme.json restored"
fi
dc_prod up -d prod
log "Restored and restarted."
}
# ─── MQTT Test ────────────────────────────────────────────────────────────
cmd_mqtt_test() {
if ! container_running "corescope-prod"; then
err "Container not running. Start with: ./manage.sh start"
exit 1
fi
info "Listening for MQTT messages (10 second timeout)..."
MSG=$(docker exec corescope-prod mosquitto_sub -h localhost -t 'meshcore/#' -C 1 -W 10 2>/dev/null)
if [ -n "$MSG" ]; then
log "Received MQTT message:"
echo " $MSG" | head -c 200
echo ""
else
warn "No messages received in 10 seconds."
echo ""
echo " This means no observer is publishing packets."
echo " See the deployment guide for connecting observers."
fi
}
# ─── Reset ────────────────────────────────────────────────────────────────
cmd_reset() {
echo ""
warn "This will remove all containers, images, and setup state."
warn "Your config.json, Caddyfile, and data directory are NOT deleted."
echo ""
if ! confirm "Continue?"; then
echo " Aborted."
exit 0
fi
dc_prod down --rmi local 2>/dev/null || true
dc_staging down --rmi local 2>/dev/null || true
rm -f "$STATE_FILE"
log "Reset complete. Run './manage.sh setup' to start over."
echo " Data directory: $PROD_DATA (not removed)"
}
# ─── Help ─────────────────────────────────────────────────────────────────
cmd_help() {
echo ""
echo "CoreScope — Management Script"
echo ""
echo "Usage: ./manage.sh <command>"
echo ""
printf '%b\n' " ${BOLD}Setup${NC}"
echo " setup First-time setup wizard (safe to re-run)"
echo " reset Remove container + image (keeps data + config)"
echo ""
printf '%b\n' " ${BOLD}Run${NC}"
echo " start Start production container"
echo " start --with-staging Start production + staging-go (copies prod DB + config)"
echo " stop [prod|staging|all] Stop specific or all containers (default: all)"
echo " restart [prod|staging|all] Restart specific or all containers"
echo " status Show health, stats, and service status"
echo " logs [prod|staging] [N] Follow logs (default: prod, last 100 lines)"
echo ""
printf '%b\n' " ${BOLD}Maintain${NC}"
echo " update [version] Update to version (no arg=latest tag, 'latest'=master tip, or e.g. v3.1.0)"
echo " promote Promote staging → production (backup + restart)"
echo " backup [dir] Full backup: database + config + theme"
echo " restore <d> Restore from backup dir or .db file"
echo " mqtt-test Check if MQTT data is flowing"
echo ""
echo "Prod uses docker-compose.yml; staging uses ${STAGING_COMPOSE_FILE}."
echo ""
}
# ─── Main ─────────────────────────────────────────────────────────────────
case "${1:-help}" in
setup) cmd_setup ;;
start) cmd_start "$2" ;;
stop) cmd_stop "$2" ;;
restart) cmd_restart "$2" ;;
status) cmd_status ;;
logs) cmd_logs "$2" "$3" ;;
update) cmd_update "$2" ;;
promote) cmd_promote ;;
backup) cmd_backup "$2" ;;
restore) cmd_restore "$2" ;;
mqtt-test) cmd_mqtt_test ;;
reset) cmd_reset ;;
help|*) cmd_help ;;
esac