dsc-rs 0.10.28

Discourse CLI tool for managing multiple Discourse forums: track installs, run upgrades over SSH, manage emojis, sync topics and categories as Markdown, and more.
Documentation
#!/usr/bin/env bash
#
# Serve the docs locally with hot-reload. Binds the first free port in
# 8000-8030, so it never clashes with another project's docs server already
# running (common when working across repos). Pass your own `-a`/`--dev-addr`
# to override the auto-pick.
#
# Requires `zensical` — install with `pip install -r requirements.txt`
# in a venv, then re-run this script with that venv activated (or
# invoke ./.venv/bin/zensical directly).
#
# Common gotcha on Linux: Zensical's watcher consumes inotify
# instances. The kernel default (128 per user) runs out quickly on
# a dev machine with editors and file-syncers already open. If you
# see `Too many open files` / inotify panics, raise the cap:
#
#     sudo sysctl fs.inotify.max_user_instances=512
#
# Persist it with:
#
#     echo 'fs.inotify.max_user_instances=512' \
#       | sudo tee /etc/sysctl.d/99-inotify.conf
#     sudo sysctl --system
#

set -euo pipefail

# Preflight: Zensical's watcher fails at inotify_init1 if the user's
# cap (fs.inotify.max_user_instances) is saturated by other running
# processes — typically VS Code, file syncers, etc. Count what's
# actually held and compare to the sysctl cap.
limit=$(sysctl -n fs.inotify.max_user_instances 2>/dev/null || echo 0)
held=$(find /proc -maxdepth 3 -user "$(id -u)" -name fd 2>/dev/null \
  | xargs -I{} ls -l {} 2>/dev/null \
  | grep -c 'anon_inode:inotify' || true)

if [[ "$limit" -gt 0 && "$held" -ge "$limit" ]]; then
  cat >&2 <<EOF
ERROR: no free inotify instances for Zensical's file watcher
  cap:  fs.inotify.max_user_instances = $limit
  held: $held (by existing processes in your user session)

Fix (this session):
  sudo sysctl fs.inotify.max_user_instances=$(( limit * 2 < 512 ? 512 : limit * 2 ))

Persist across reboots:
  echo 'fs.inotify.max_user_instances=512' | sudo tee /etc/sysctl.d/99-inotify.conf
  sudo sysctl --system

Then re-run $0.
EOF
  exit 1
fi

# Non-fatal: getting close to the cap — warn but proceed.
if [[ "$limit" -gt 0 && "$held" -ge $(( limit - 4 )) ]]; then
  echo >&2 "warning: only $(( limit - held )) free inotify instances (cap $limit, held $held)"
  echo >&2 "         if Zensical panics with \"Too many open files\", raise the cap:"
  echo >&2 "           sudo sysctl fs.inotify.max_user_instances=$(( limit * 2 ))"
  echo >&2
fi

# Pick the first free port in 8000-8030 so concurrent docs servers (one per
# repo) don't fight over 8000 — unless the caller passed their own address.
PORT_MIN=8000
PORT_MAX=8030

addr_given=0
for arg in "$@"; do
  case "$arg" in
    -a | --dev-addr | --dev-addr=*) addr_given=1; break ;;
  esac
done

# True when something is already LISTENING on $1, on ANY address and either
# IP family. zensical binds `localhost`, which often resolves to IPv6 `::1`,
# so an IPv4-only connect probe misses it - use `ss` (Linux), then `lsof`
# (macOS/BSD), then a best-effort connect probe.
port_in_use() {
  if command -v ss >/dev/null 2>&1; then
    ss -ltnH 2>/dev/null | awk '{print $4}' | awk -F: '{print $NF}' | grep -qx "$1"
  elif command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
  else
    (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
  fi
}

if [[ "$addr_given" -eq 0 ]]; then
  chosen=""
  for port in $(seq "$PORT_MIN" "$PORT_MAX"); do
    if ! port_in_use "$port"; then
      chosen="$port"
      break
    fi
  done
  if [[ -n "$chosen" ]]; then
    echo >&2 "Serving docs on http://localhost:$chosen"
    set -- --dev-addr "localhost:$chosen" "$@"
  else
    echo >&2 "warning: no free port in ${PORT_MIN}-${PORT_MAX}; letting zensical use its default"
  fi
fi

exec zensical serve "$@"