SDI Lab On-site AI box Demo. Muster Feinmechanik AG and its documents are made up, and all prices and specs are examples. Everything runs in your browser; nothing you type is sent anywhere.

Install script

Turns a fresh Mac into a box. By default it only prints what it would do.

Download install.sh Draft script. Check the SHA-256 below before running it.

Run it

bash install.sh

Dry run: checks the Mac and prints the plan. Changes nothing.

bash install.sh --apply

Makes the changes, asking before every step. A failed step is reported and the rest still runs.

Run it as an admin user, not with sudo. It downloads the runtime and a model, never deletes files and sends no data of yours anywhere.

Check the file

SHA-256 of install.sh. Compare it with the output of the command below.

3d0ad8d36e70e688e04ad45750d693ca2b02d753c7c889122423da2547f2b330
shasum -a 256 install.sh

What it does

  1. Checks macOS and memory

    macOS version, Apple silicon and memory. Read-only.

  2. Checks FileVault and the firewall

    If either is off, it says where to turn it on. Read-only.

  3. Installs the AI runtime

    Installs Ollama with Homebrew, for local use only. Asks first.

  4. Downloads a model

    Picks one that fits the memory and the free disk space. Asks first.

  5. Sets up the document app

    Makes it start with the Mac. Never overwrites anything. Asks first.

  6. Explains the maintenance link

    The box opens an outgoing-only link to SDI, and SDI's maintenance sessions come back through it. How to set it up and cut it. Prints only.

The script
#!/usr/bin/env bash
# SDI on-site AI box: install script (v1 draft)
#
# Turns a fresh Mac into an on-site AI box.
#
#   bash install.sh            Dry run (default): checks the Mac and prints the plan. Changes nothing.
#   bash install.sh --apply    Makes the changes. Asks before every step; anything but "y" skips it.
#   bash install.sh --help     Shows this help.
#
# What --apply changes, each only after you answer "y":
#   - installs Ollama with Homebrew and starts it for this user (listening on 127.0.0.1 only);
#   - downloads one model (several GB; refused if free disk space is short);
#   - adds a launchd agent and a placeholder start script (~/Library/LaunchAgents, ~/sdi-box).
# The script itself never deletes or overwrites files and never changes system or security settings.
# Network: it downloads the runtime and a model; it sends no data of yours anywhere.
# It opens no port to the internet. The maintenance tunnel (step 6) is set up by hand, with SDI.
#
# Run the install as an admin user (Homebrew needs one), never with sudo. Day to day, the
# document app should run under a standard user; step 5 explains how.
#
# If one step fails, the script says so and goes on with the next.
#
# Status: v1 draft. Read it before you run it. Model names and sizes are examples; check them first.
# Works with the bash that ships with macOS (3.2).

set -euo pipefail

SCRIPT_VERSION="v1 draft"
APPLY=0

# ---- Example settings (override with environment variables) ----
MIN_MACOS_MAJOR="${MIN_MACOS_MAJOR:-14}"
MODEL_LARGE="${MODEL_LARGE:-gpt-oss:120b}"   # 96 GB memory or more
MODEL_MEDIUM="${MODEL_MEDIUM:-qwen3:30b}"    # 48 to 95 GB
MODEL_SMALL="${MODEL_SMALL:-qwen3:14b}"      # 24 to 47 GB
MODEL_TINY="${MODEL_TINY:-qwen3:4b}"         # under 24 GB: below the box spec, demo only
DOCS_APP_PORT="${DOCS_APP_PORT:-8080}"       # the document app listens on 127.0.0.1 only

BOX_HOME="${HOME}/sdi-box"
AGENT_LABEL="ch.strategic-data-insights.box.docs"
AGENT_DIR="${HOME}/Library/LaunchAgents"
AGENT_PLIST="${AGENT_DIR}/${AGENT_LABEL}.plist"
TUNNEL_LABEL="ch.strategic-data-insights.box.tunnel"

IS_MAC=0
MEM_GB=0
PROBLEMS=0

# ---- Output helpers ----
say()  { printf '%s\n' "$*"; }
step() { printf '\n== Step %s of 6: %s\n' "$1" "$2"; }
plan() { printf '  [plan] %s\n' "$*"; }
ok()   { printf '  [ok]   %s\n' "$*"; }
warn() { printf '  [warn] %s\n' "$*"; }
info() { printf '         %s\n' "$*"; }
fail() { warn "$*"; PROBLEMS=$((PROBLEMS + 1)); }

usage() {
  cat <<'USAGE'
SDI on-site AI box: install script (v1 draft)

  bash install.sh            Dry run (default): checks the Mac and prints the plan. Changes nothing.
  bash install.sh --apply    Makes the changes. Asks before every step; anything but "y" skips it.
  bash install.sh --help     Shows this help.

Example settings can be overridden with environment variables:
  MODEL_LARGE, MODEL_MEDIUM, MODEL_SMALL, MODEL_TINY, MODEL_SIZE_GB,
  MIN_MACOS_MAJOR, DOCS_APP_PORT
USAGE
}

# Asks for a yes. Always "no" in a dry run, so a dry run can never reach a change.
confirm() {
  local answer=""
  if [ "$APPLY" -ne 1 ]; then
    return 1
  fi
  printf '  Run this step? [y/N] '
  if ! read -r answer < /dev/tty; then
    answer=""
  fi
  case "$answer" in
    y|Y|yes|Yes|YES) return 0 ;;
    *) say "  Skipped."; return 1 ;;
  esac
}

# Prints a command, then runs it. Only called after confirm, i.e. only with --apply.
# Callers wrap it in "if ! run ...", so a failed command is reported and the script goes on.
run() {
  printf '  $'
  printf ' %q' "$@"
  printf '\n'
  if [ "$APPLY" -eq 1 ]; then
    "$@"
  fi
}

# Read-only: does the local Ollama server answer?
ollama_answers() {
  ollama list > /dev/null 2>&1
}

# ---- Step 1: macOS version and memory (read-only) ----
check_system() {
  step 1 "Check macOS version and memory (read-only)"
  local os_name
  os_name="$(uname -s)"
  if [ "$os_name" != "Darwin" ]; then
    warn "This is ${os_name}, not macOS. The v1 box is a Mac; the plan below is shown for reference."
    return 0
  fi
  IS_MAC=1

  local version major arch mem_bytes
  version="$(sw_vers -productVersion 2>/dev/null || echo unknown)"
  major="${version%%.*}"
  case "$major" in ''|*[!0-9]*) major=0 ;; esac
  if [ "$major" -ge "$MIN_MACOS_MAJOR" ]; then
    ok "macOS ${version}"
  else
    warn "macOS ${version} is older than ${MIN_MACOS_MAJOR}. Update macOS before going on."
  fi

  arch="$(uname -m)"
  if [ "$arch" = "arm64" ]; then
    ok "Apple silicon (${arch})"
  else
    warn "Processor ${arch}: local models will be slow. The box needs Apple silicon."
  fi

  mem_bytes="$(sysctl -n hw.memsize 2>/dev/null || echo 0)"
  case "$mem_bytes" in ''|*[!0-9]*) mem_bytes=0 ;; esac
  MEM_GB=$(( mem_bytes / 1073741824 ))
  if [ "$MEM_GB" -ge 32 ]; then
    ok "Memory: ${MEM_GB} GB"
  else
    warn "Memory: ${MEM_GB} GB. Below the Box S example spec (32 GB); only small models will fit."
  fi
}

# ---- Step 2: FileVault and firewall (read-only; tells you how to turn them on) ----
check_security() {
  step 2 "Check FileVault and firewall (read-only)"
  if [ "$IS_MAC" -ne 1 ]; then
    plan "On the Mac: check FileVault (fdesetup status) and the firewall (socketfilterfw --getglobalstate)."
    return 0
  fi

  local fv fw stealth fwtool
  fv="$(fdesetup status 2>/dev/null || true)"
  case "$fv" in
    *"FileVault is On"*) ok "FileVault is on." ;;
    *)
      warn "FileVault is not on (${fv:-status unknown})."
      info "Turn it on: System Settings > Privacy & Security > FileVault > Turn On."
      info "Give the recovery key to the client to keep. Don't store it on the box or at SDI."
      ;;
  esac

  fwtool="/usr/libexec/ApplicationFirewall/socketfilterfw"
  if [ -x "$fwtool" ]; then
    fw="$("$fwtool" --getglobalstate 2>/dev/null || true)"
    stealth="$("$fwtool" --getstealthmode 2>/dev/null || true)"
  else
    fw=""
    stealth=""
  fi
  case "$fw" in
    *"enabled"*|*"is on"*) ok "Firewall is on." ;;
    *)
      warn "Firewall is not on (${fw:-status unknown})."
      info "Turn it on: System Settings > Network > Firewall."
      ;;
  esac
  case "$stealth" in
    *"enabled"*|*"is on"*) ok "Stealth mode is on." ;;
    *)
      warn "Stealth mode is not on."
      info "Turn it on: System Settings > Network > Firewall > Options > Enable stealth mode."
      ;;
  esac
  info "This script never changes these settings itself."
}

# ---- Step 3: local model runtime via Homebrew ----
install_runtime() {
  step 3 "Install the local model runtime (Ollama, via Homebrew)"

  if command -v ollama > /dev/null 2>&1; then
    ok "Ollama is already installed: $(command -v ollama)"
    if ollama_answers; then
      ok "The Ollama server answers."
      return 0
    fi
    warn "The Ollama server doesn't answer, so step 4 can't download a model yet."
    if command -v brew > /dev/null 2>&1; then
      plan "brew services start ollama   (runs for this user; listens on 127.0.0.1:11434 only)"
      if confirm; then
        if ! run env HOMEBREW_NO_ANALYTICS=1 brew services start ollama; then
          fail "Could not start Ollama with Homebrew."
          info "If Ollama came from the Ollama app, open the app instead, or run 'ollama serve' in another terminal."
        fi
      fi
    else
      info "Start it: open the Ollama app, or run 'ollama serve' in another terminal."
    fi
    return 0
  fi

  if ! command -v brew > /dev/null 2>&1; then
    warn "Homebrew is not installed, so this step is skipped."
    info "Install Homebrew from https://brew.sh (read its script first), then run this script again."
    return 0
  fi

  plan "env HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_NO_ANALYTICS=1 brew install ollama"
  info "For this run: Homebrew doesn't update itself, doesn't clean up old versions and sends no analytics."
  plan "brew services start ollama   (runs for this user; listens on 127.0.0.1:11434 only)"
  if confirm; then
    if ! run env HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_UPGRADE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_NO_ANALYTICS=1 brew install ollama; then
      fail "brew install ollama failed. The next steps still run; step 4 will skip the download."
      return 0
    fi
    if ! run env HOMEBREW_NO_ANALYTICS=1 brew services start ollama; then
      fail "Could not start the Ollama server. Try: brew services start ollama"
    fi
  fi
}

# ---- Step 4: pull a model that fits the memory and the disk ----
choose_model() {
  if [ "$MEM_GB" -ge 96 ]; then
    printf '%s' "$MODEL_LARGE"
  elif [ "$MEM_GB" -ge 48 ]; then
    printf '%s' "$MODEL_MEDIUM"
  elif [ "$MEM_GB" -ge 24 ]; then
    printf '%s' "$MODEL_SMALL"
  else
    printf '%s' "$MODEL_TINY"
  fi
}

# Approximate download size in whole GB (rounded up). Example figures; MODEL_SIZE_GB overrides.
model_size_gb() {
  if [ -n "${MODEL_SIZE_GB:-}" ]; then
    printf '%s' "$MODEL_SIZE_GB"
    return 0
  fi
  case "$1" in
    gpt-oss:120b) printf '65' ;;
    qwen3:30b)    printf '19' ;;
    qwen3:14b)    printf '10' ;;
    qwen3:4b)     printf '3' ;;
    *)            printf '' ;;
  esac
}

# Read-only: free space in whole GB on the volume that holds the home folder (Ollama stores models there).
free_gb() {
  df -Pk "$HOME" 2> /dev/null | awk 'NR == 2 { printf "%d", $4 / 1048576 }'
}

pull_model() {
  step 4 "Download a model that fits the memory and the disk"
  local model size need free can_pull=1
  model="$(choose_model)"
  plan "ollama pull ${model}   (chosen for ${MEM_GB} GB memory)"
  info "Rule: 96 GB+ ${MODEL_LARGE}; 48 GB+ ${MODEL_MEDIUM}; 24 GB+ ${MODEL_SMALL}; less ${MODEL_TINY}."
  info "Example model names: check the current model library and licences first."

  size="$(model_size_gb "$model")"
  case "$size" in
    ''|*[!0-9]*)
      warn "Download size of ${model} is unknown. Set MODEL_SIZE_GB=<size in GB> to allow the download."
      can_pull=0
      ;;
    *)
      need=$(( (size * 12 + 9) / 10 ))
      info "Download: about ${size} GB. Needs ${need} GB free (size + 20%)."
      free="$(free_gb || true)"
      case "$free" in
        ''|*[!0-9]*)
          warn "Could not read the free disk space, so the download is refused."
          can_pull=0
          ;;
        *)
          if [ "$free" -ge "$need" ]; then
            ok "Free disk space: ${free} GB."
          else
            warn "Free disk space: ${free} GB. Not enough for this model, so the download is refused."
            can_pull=0
          fi
          ;;
      esac
      ;;
  esac

  if ! command -v ollama > /dev/null 2>&1; then
    if [ "$APPLY" -eq 1 ]; then
      fail "Ollama is not installed (step 3), so the download is skipped."
    else
      info "Before downloading, the script checks that the Ollama server answers."
    fi
    return 0
  fi
  if ollama_answers; then
    ok "The Ollama server answers."
  else
    warn "The Ollama server doesn't answer, so the download is skipped."
    info "Start it with 'brew services start ollama' (or open the Ollama app, or run 'ollama serve'),"
    info "then run this script again."
    can_pull=0
  fi

  if [ "$can_pull" -ne 1 ]; then
    if [ "$APPLY" -eq 1 ]; then
      PROBLEMS=$((PROBLEMS + 1))
    fi
    return 0
  fi
  if confirm; then
    if ! run ollama pull "$model"; then
      fail "The download of ${model} failed. Run 'ollama pull ${model}' again later; it resumes."
    fi
  fi
}

# ---- Step 5: launchd agent for the document app ----
write_start_script() {
  local target="$1"
  ( set -o noclobber; cat > "$target" ) <<'START' || return 1
#!/usr/bin/env bash
# Placeholder written by the SDI box install script (v1 draft).
# The document app is not part of v1. SDI replaces this file when it installs the app.
echo "SDI document app is not installed yet (install script v1 draft)."
exit 0
START
  chmod 755 "$target"
}

write_plist() {
  local target="$1"
  ( set -o noclobber; cat > "$target" ) <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>${AGENT_LABEL}</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>${BOX_HOME}/docs-app/start.sh</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOST</key><string>127.0.0.1</string>
    <key>PORT</key><string>${DOCS_APP_PORT}</string>
    <key>OLLAMA_HOST</key><string>http://127.0.0.1:11434</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
  <key>ThrottleInterval</key><integer>30</integer>
  <key>StandardOutPath</key><string>${BOX_HOME}/logs/docs-app.log</string>
  <key>StandardErrorPath</key><string>${BOX_HOME}/logs/docs-app.err.log</string>
</dict>
</plist>
PLIST
}

create_agent() {
  step 5 "Create a launchd agent for the document app"
  local uid
  uid="$(id -u)"

  case "$DOCS_APP_PORT" in
    ''|*[!0-9]*)
      fail "DOCS_APP_PORT must be a number (got '${DOCS_APP_PORT}'), so this step is skipped."
      return 0
      ;;
  esac
  if [ "${#DOCS_APP_PORT}" -gt 5 ] || [ "$DOCS_APP_PORT" -lt 1024 ] || [ "$DOCS_APP_PORT" -gt 65535 ]; then
    fail "DOCS_APP_PORT must be between 1024 and 65535 (got ${DOCS_APP_PORT}), so this step is skipped."
    return 0
  fi

  plan "mkdir -p ${BOX_HOME}/docs-app ${BOX_HOME}/logs"
  plan "Write ${BOX_HOME}/docs-app/start.sh (a placeholder; only if it doesn't exist)"
  plan "Write ${AGENT_PLIST} (only if it doesn't exist; never overwrites)"
  plan "launchctl bootstrap gui/${uid} ${AGENT_PLIST}"
  info "The app will listen on 127.0.0.1:${DOCS_APP_PORT} only."
  info "Recommended: run the document app under a dedicated standard user (for example 'sdi-box'),"
  info "not the admin account. Create that user in System Settings > Users & Groups, log in as it,"
  info "and run 'bash install.sh --apply' there, answering 'y' to this step only."

  if [ -e "$AGENT_PLIST" ]; then
    warn "${AGENT_PLIST} already exists. Leaving it alone; this step is skipped."
    return 0
  fi
  case "$HOME" in
    *'<'*|*'>'*|*'&'*|*'"'*)
      fail "The home folder path has characters that don't fit in a plist. Skipped; set it up by hand."
      return 0
      ;;
  esac

  if confirm; then
    if ! run mkdir -p "${BOX_HOME}/docs-app" "${BOX_HOME}/logs" "$AGENT_DIR"; then
      fail "Could not create the folders, so this step stops here."
      return 0
    fi
    if [ -e "${BOX_HOME}/docs-app/start.sh" ]; then
      ok "${BOX_HOME}/docs-app/start.sh exists; keeping it."
    elif write_start_script "${BOX_HOME}/docs-app/start.sh"; then
      ok "Wrote ${BOX_HOME}/docs-app/start.sh"
    else
      fail "Could not write ${BOX_HOME}/docs-app/start.sh, so this step stops here."
      return 0
    fi
    if ! write_plist "$AGENT_PLIST"; then
      fail "Could not write ${AGENT_PLIST}, so this step stops here."
      return 0
    fi
    ok "Wrote ${AGENT_PLIST}"
    if ! run plutil -lint "$AGENT_PLIST"; then
      fail "The agent file failed 'plutil -lint', so it is not loaded. Check ${AGENT_PLIST} by hand."
      return 0
    fi
    if ! run launchctl bootstrap "gui/${uid}" "$AGENT_PLIST"; then
      fail "Could not load the agent. Try: launchctl bootstrap gui/${uid} ${AGENT_PLIST}"
    fi
  fi
}

# ---- Step 6: outgoing tunnel (instructions only) ----
print_tunnel() {
  step 6 "Outgoing-only maintenance tunnel (instructions only; nothing is changed)"
  cat <<TUNNEL
  No port on the box is open to the internet. The box opens the tunnel itself;
  SDI's maintenance sessions come back through it, and you can cut it at any time.
  To set it up, together with SDI:

  1. Turn on Remote Login for the admin account only, with SSH keys only:
       System Settings > General > Sharing > Remote Login
  2. Make a key for the tunnel, on the box, as the admin user:
       ssh-keygen -t ed25519 -f ~/.ssh/sdi_box_tunnel -C "sdi-box-<client>"
  3. Send SDI the public key only (~/.ssh/sdi_box_tunnel.pub). Never send the private key.
  4. SDI adds it to a locked-down account on its relay server (port forwarding only)
     and gives you the relay host name and a port number for this box.
  5. A launchd agent (${TUNNEL_LABEL}) keeps this outgoing connection open.
     SDI's sessions reach the box's SSH through it:
       ssh -N -i ~/.ssh/sdi_box_tunnel -o ExitOnForwardFailure=yes \\
           -o ServerAliveInterval=30 -R <port>:127.0.0.1:22 tunnel@<relay-host>
  6. To cut SDI off at any time, unplug the network cable, or run:
       launchctl bootout gui/\$(id -u)/${TUNNEL_LABEL}
TUNNEL
}

main() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --apply) APPLY=1 ;;
      -h|--help) usage; exit 0 ;;
      *) say "Unknown option: $1"; say ""; usage; exit 2 ;;
    esac
    shift
  done

  say "SDI on-site AI box: install script (${SCRIPT_VERSION})"
  if [ "$APPLY" -eq 1 ]; then
    say "Mode: APPLY. Each step asks first; anything but \"y\" skips it."
    if [ "$(uname -s)" != "Darwin" ]; then
      say "--apply only runs on macOS. Run it without --apply here to see the plan."
      exit 1
    fi
    if [ "$(id -u)" -eq 0 ]; then
      say "Don't run this with sudo. Run it as an admin user; Homebrew asks for the password itself if needed."
      exit 1
    fi
    if ! { : < /dev/tty; } 2> /dev/null; then
      say "--apply needs an interactive terminal, so each step can ask first."
      exit 1
    fi
  else
    say "Mode: DRY RUN. Checks the Mac and prints the plan. Nothing is changed."
  fi

  check_system
  check_security
  install_runtime
  pull_model
  create_agent
  print_tunnel

  say ""
  if [ "$APPLY" -eq 1 ]; then
    if [ "$PROBLEMS" -gt 0 ]; then
      say "Finished with ${PROBLEMS} problem(s); see the [warn] lines above. Fix them and run the script again."
    else
      say "Done. Now work through the security checklist."
    fi
  else
    say "Dry run finished. Nothing was changed."
    say "To make these changes, run:  bash install.sh --apply"
  fi
}

main "$@"