#!/bin/sh # install.sh — one-command quick install for Annotide (Community edition without a key). # # curl -fsSL https://annotide.com/install.sh | sh # # POSIX sh, Linux and macOS, never needs root, safe to re-run (re-running # never touches an existing .env or your data). The whole script is wrapped # in main(), called at the very end, so a `curl | sh` truncated by a flaky # connection can only ever run a syntax error, never half a script. # # Environment variables (all optional): # ANNOTIDE_DIR Install directory (default: $HOME/annotide) # ANNOTIDE_BASE_URL Where to fetch compose.yaml from (default: https://annotide.com) # ANNOTIDE_COMPOSE_FILE Path to a local compose.yaml instead of downloading one # ANNOTIDE_PORT Host port for the web UI (default: 8080, loopback only) # ANNOTIDE_REGISTRY Image registry/namespace (default: ghcr.io/annotide) # ANNOTIDE_VERSION Image tag (default: latest) # ADMIN_EMAIL Skip the e-mail prompt when creating the first admin # ADMIN_PASSWORD Skip the password prompt (min. 12 characters) # COMPOSE_PROJECT_NAME Override the Compose project name (used by our own tests) set -eu log() { printf '%s\n' "$*"; } err() { printf 'Error: %s\n' "$*" >&2; } require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then err "$1 is required but was not found on PATH. $2" exit 1 fi } # A random hex string of $1 bytes (so $1 * 2 hex characters), preferring # openssl (present almost everywhere) and falling back to /dev/urandom. random_hex() { length="$1" if command -v openssl >/dev/null 2>&1; then openssl rand -hex "$length" else od -An -tx1 -N "$length" /dev/urandom | tr -d ' \n' fi } check_prerequisites() { require_cmd docker "Install Docker Desktop or Docker Engine: https://docs.docker.com/get-docker/" require_cmd curl "Install curl, or set ANNOTIDE_COMPOSE_FILE to a local copy of compose.yaml." if ! docker compose version >/dev/null 2>&1; then err "Docker Compose v2 is required (the 'docker compose' subcommand)." err "Docker Desktop and recent Docker Engine packages include it; see https://docs.docker.com/compose/install/" exit 1 fi if ! docker info >/dev/null 2>&1; then err "Docker does not seem to be running (or this user cannot reach it)." err "On Linux, make sure your user is in the 'docker' group: https://docs.docker.com/engine/install/linux-postinstall/" exit 1 fi } fetch_compose_file() { dir="$1" if [ -n "${ANNOTIDE_COMPOSE_FILE:-}" ]; then log "Using local compose file: $ANNOTIDE_COMPOSE_FILE" cp "$ANNOTIDE_COMPOSE_FILE" "$dir/compose.yaml" else base_url="${ANNOTIDE_BASE_URL:-https://annotide.com}" log "Downloading compose.yaml from $base_url/install/compose.yaml" curl -fsSL "$base_url/install/compose.yaml" -o "$dir/compose.yaml.tmp" mv "$dir/compose.yaml.tmp" "$dir/compose.yaml" fi } # Stops the install when there is no .env but the Postgres volume of an # earlier install with the same Compose project name is still there (its # directory removed without `docker compose down -v`, or a second install # directory: compose.yaml sets `name: annotide`, so every directory shares # it). Postgres only applies POSTGRES_PASSWORD to an empty volume, so a new # .env would never match the old database and the backend would crash-loop # on "password authentication failed". Never deletes anything itself. check_leftover_database() { dir="$1" # Compose resolves the name exactly as `up` will (COMPOSE_PROJECT_NAME, # then compose.yaml's `name:`, then the directory). --no-interpolate: # the required ${VAR:?} values do not exist yet. project=$(cd "$dir" && docker compose config --no-interpolate 2>/dev/null \ | sed -n 's/^name:[[:space:]]*//p' | head -n 1) || project="" [ -n "$project" ] || return 0 volume="${project}_postgres_data" docker volume inspect "$volume" >/dev/null 2>&1 || return 0 # `docker volume rm` refuses a volume that a container, even a stopped # one, still uses: name the old containers too. containers=$(docker ps -a --filter "label=com.docker.compose.project=$project" \ --format '{{.Names}}' 2>/dev/null | tr '\n' ' ' | sed 's/ *$//') || containers="" if [ -n "$containers" ]; then remove_cmd="docker rm -f $containers && docker volume rm $volume" else remove_cmd="docker volume rm $volume" fi err "An earlier Annotide install left its database behind, but $dir has no .env." cat >&2 < "$env_file" < Licence in the app. # APP_LICENSE_KEY= # The licence heartbeat (docs/LICENSING.md) is on by default and sends only # salted hashes. Uncomment to turn it off entirely: # APP_TELEMETRY_ENABLED=false # Start the bundled reference model service (an offline heuristic detector, # no GPU, no weights) alongside the rest of the stack: # COMPOSE_PROFILES=models # Or SAM2 for the Smart polygon tool (CPU, a ~1 GB image), or both: # COMPOSE_PROFILES=sam2 # COMPOSE_PROFILES=models,sam2 # Pin a specific release, or point at a different registry/mirror: # ANNOTIDE_VERSION=latest # ANNOTIDE_REGISTRY=ghcr.io/annotide EOF ) log "Wrote $env_file with a generated database password and secret key." } start_stack() { dir="$1" log "Pulling images..." # --ignore-pull-failures: lets this succeed against images that only # exist locally (our own test harness uses a fake registry/tag); a real # install always has something to pull, so a hard failure there still # shows up on the `up` step below. ( cd "$dir" && docker compose pull --ignore-pull-failures ) log "Starting Annotide..." ( cd "$dir" && docker compose up -d --wait ) } # True when /dev/tty can actually be opened. `[ -r /dev/tty ]` is not # enough: the device node exists (and is readable) even when the process has # no controlling terminal (cron, `ssh host 'curl ... | sh'`, cloud-init, # CI), and opening it then fails with ENXIO. tty_usable() { ( : /dev/null } # Reads $1 (a prompt) from the controlling terminal and echoes the answer # into $2 (a variable name, via eval — there is no `read -r var /dev/tty IFS= read -r REPLY_VALUE /dev/tty stty_saved="" if command -v stty >/dev/null 2>&1; then stty_saved=$(stty -g /dev/null) || stty_saved="" [ -n "$stty_saved" ] && stty -echo /dev/null || true fi read_status=0 IFS= read -r REPLY_VALUE /dev/null || true fi printf '\n' >/dev/tty return "$read_status" } # Creates the first administrator account. There is no sign-up flow by # design (AUTH-2): `python -m app.cli create-superuser`, documented in # backend/app/cli.py, is the only way to create one outside the demo # seeder, and is exactly what this calls. create_first_admin() { dir="$1" admin_email="${ADMIN_EMAIL:-}" admin_password="${ADMIN_PASSWORD:-}" if [ -z "$admin_email" ]; then log "" log "Create the first administrator." if prompt_visible "E-mail: "; then admin_email="$REPLY_VALUE" fi fi if [ -z "$admin_email" ]; then cat <