feat: add scripts/run-container.sh for launching the Fusion container

The correct `docker run` is long and every piece of it fails confusingly when
omitted: the OAuth callback ports are fixed by the providers' registered redirect
URIs and unreachable without PI_OAUTH_CALLBACK_HOST=0.0.0.0, /home/node must be a
volume or the Tailscale login and embedded Postgres are lost on recreate, and
--tailscale is an entrypoint flag that must precede the CLI arguments.

- Every knob is an environment variable, so per-container config lives in an
  --env-file outside the repo rather than a committed token.
- FUSION_STATE_VOLUME supports setups that mount /home/node/.fusion as its own
  volume nested inside /home/node; omitting it is not destructive but silently
  points the dashboard at a different database, so the board comes up empty.
- An existing container is never replaced without --recreate, and volumes are
  never removed, so a recreate keeps the database, settings, and tailnet login.
- --dry-run prints the command without running it, including when the container
  already exists, which is exactly when previewing a recreate is most useful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-22 19:21:31 -07:00
parent e6121bc8e2
commit 2f8d2e8ee0
2 changed files with 198 additions and 0 deletions

177
scripts/run-container.sh Executable file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# FNXC:DockerRun 2026-08-23-02:20:
# Launch the Fusion container with a complete, correct argument list.
#
# It exists because the correct `docker run` is long and every piece of it is load-bearing in a way
# that fails confusingly when omitted: the OAuth callback ports are fixed by the providers' registered
# redirect URIs and unreachable without `PI_OAUTH_CALLBACK_HOST=0.0.0.0`; `/home/node` must be a
# volume or the Tailscale login and embedded Postgres are lost on every recreate; and `--tailscale`
# is an entrypoint flag that must precede the CLI arguments. Reconstructing that by hand each time is
# how a container ends up subtly wrong.
#
# Safety: an existing container is NEVER replaced without `--recreate`. Volumes are never touched, so
# a recreate keeps the database, settings, and tailnet identity.
set -euo pipefail
IMAGE="${FUSION_IMAGE:-fusion:latest}"
NAME="${FUSION_CONTAINER_NAME:-fusion}"
HOST_PORT="${FUSION_HOST_PORT:-4040}"
CONTAINER_PORT="${FUSION_CONTAINER_PORT:-4040}"
# Volume at /home/node covers BOTH /home/node/.fusion (embedded Postgres, settings) and
# /home/node/.tailscale (node login), so one mount survives a recreate intact.
HOME_VOLUME="${FUSION_HOME_VOLUME:-fusion-home}"
WORKSPACE_VOLUME="${FUSION_WORKSPACE_VOLUME:-fusion-workspace}"
# Optional SEPARATE volume nested at /home/node/.fusion, for setups that mounted global state before
# the /home/node mount existed. Leave it unset and .fusion simply lives inside the /home/node volume.
# Getting this wrong is not destructive but is confusing: the dashboard silently reads a DIFFERENT
# database than the one the old container used, so the board comes up empty.
STATE_VOLUME="${FUSION_STATE_VOLUME:-}"
RESTART_POLICY="${FUSION_RESTART_POLICY:-unless-stopped}"
TAILSCALE="${FUSION_TAILSCALE:-0}"
build=0
recreate=0
dry_run=0
env_file=""
usage() {
cat <<'USAGE'
Usage: scripts/run-container.sh [options]
Options:
--build Rebuild the image from the repo before starting
--tailscale Start tailscaled in the container (userspace mode)
--no-tailscale Force the daemon off, overriding an env file
--recreate Replace an existing container of the same name
--env-file <path> Source shell variable assignments before running
--dry-run Print the docker command without running it
-h, --help Show this help
Configuration (env vars, or set them in --env-file):
FUSION_IMAGE image tag (default fusion:latest)
FUSION_CONTAINER_NAME container name (default fusion)
FUSION_HOST_PORT host port (default 4040)
FUSION_CONTAINER_PORT in-container port (default 4040)
FUSION_HOME_VOLUME volume for /home/node (default fusion-home)
FUSION_STATE_VOLUME optional separate volume for /home/node/.fusion
FUSION_WORKSPACE_VOLUME volume or host path for /workspace
FUSION_DASHBOARD_TOKEN dashboard auth token (optional)
FUSION_RESTART_POLICY docker restart policy (default unless-stopped)
FUSION_TAILSCALE 1 to start tailscaled (default 0)
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--build) build=1 ;;
--tailscale) TAILSCALE=1 ;;
--no-tailscale) TAILSCALE=0 ;;
--recreate) recreate=1 ;;
--dry-run) dry_run=1 ;;
--env-file) env_file="${2:-}"; [ -n "$env_file" ] || { echo "--env-file needs a path" >&2; exit 2; }; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
# The env file is sourced AFTER flag parsing but its values must not silently beat an explicit flag,
# so the two flags that can appear in both places are re-applied below.
if [ -n "$env_file" ]; then
[ -f "$env_file" ] || { echo "env file not found: $env_file" >&2; exit 1; }
# shellcheck disable=SC1090
. "$env_file"
IMAGE="${FUSION_IMAGE:-$IMAGE}"
NAME="${FUSION_CONTAINER_NAME:-$NAME}"
HOST_PORT="${FUSION_HOST_PORT:-$HOST_PORT}"
CONTAINER_PORT="${FUSION_CONTAINER_PORT:-$CONTAINER_PORT}"
HOME_VOLUME="${FUSION_HOME_VOLUME:-$HOME_VOLUME}"
STATE_VOLUME="${FUSION_STATE_VOLUME:-$STATE_VOLUME}"
WORKSPACE_VOLUME="${FUSION_WORKSPACE_VOLUME:-$WORKSPACE_VOLUME}"
RESTART_POLICY="${FUSION_RESTART_POLICY:-$RESTART_POLICY}"
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ "$build" = "1" ]; then
echo "==> Building $IMAGE"
docker build -t "$IMAGE" "$repo_root"
fi
# --dry-run is inspection, not a launch, so it must print the command even when the container
# already exists — the existence guard below would otherwise make it useless in the exact case where
# you most want to preview what a recreate would run.
if [ "$dry_run" != "1" ] && docker container inspect "$NAME" >/dev/null 2>&1; then
if [ "$recreate" != "1" ]; then
echo "Container '$NAME' already exists. Re-run with --recreate to replace it." >&2
echo "(Volumes are preserved, so its database and Tailscale login survive.)" >&2
exit 1
fi
echo "==> Removing existing container $NAME"
docker rm -f "$NAME" >/dev/null
fi
args=(
run -d
--name "$NAME"
--restart "$RESTART_POLICY"
-p "${HOST_PORT}:${CONTAINER_PORT}"
# Fixed by the providers' registered OAuth redirect URIs: 53692 Anthropic, 1455 OpenAI Codex.
# They cannot be remapped to other host ports, and the in-container listener binds 127.0.0.1
# unless PI_OAUTH_CALLBACK_HOST opens it, so publishing alone is not enough.
-p 53692:53692
-p 1455:1455
-e PI_OAUTH_CALLBACK_HOST=0.0.0.0
-e NODE_ENV=production
-e "PORT=${CONTAINER_PORT}"
-v "${HOME_VOLUME}:/home/node"
-v "${WORKSPACE_VOLUME}:/workspace"
)
if [ -n "$STATE_VOLUME" ]; then
args+=(-v "${STATE_VOLUME}:/home/node/.fusion")
fi
if [ -n "${FUSION_DASHBOARD_TOKEN:-}" ]; then
args+=(-e "FUSION_DASHBOARD_TOKEN=${FUSION_DASHBOARD_TOKEN}")
fi
args+=("$IMAGE")
# --tailscale is an ENTRYPOINT flag: it is consumed and stripped by docker-entrypoint.sh, so it must
# come before the Fusion CLI arguments, not after.
if [ "$TAILSCALE" = "1" ]; then
args+=(--tailscale)
fi
args+=(dashboard --host 0.0.0.0 --port "$CONTAINER_PORT")
if [ "$dry_run" = "1" ]; then
printf 'docker'; printf ' %q' "${args[@]}"; printf '\n'
exit 0
fi
echo "==> Starting $NAME from $IMAGE"
docker "${args[@]}" >/dev/null
echo "==> Waiting for the dashboard to answer"
deadline=$(( $(date +%s) + 300 ))
until curl -sf -o /dev/null "http://localhost:${HOST_PORT}/api/health"; do
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "Dashboard did not become healthy within 5 minutes. Recent logs:" >&2
docker logs --tail 40 "$NAME" >&2
exit 1
fi
sleep 3
done
echo "==> Ready: http://localhost:${HOST_PORT}"
if [ "$TAILSCALE" = "1" ]; then
if docker exec "$NAME" tailscale status >/dev/null 2>&1; then
echo "==> Tailscale: logged in as $(docker exec "$NAME" tailscale status --json | sed -n 's/.*"DNSName": "\([^"]*\)\..*/\1/p' | head -1)"
else
echo "==> Tailscale: daemon running but LOGGED OUT."
echo " Authenticate once with: docker exec -it $NAME tailscale up"
fi
fi