Install script
Turns a fresh Mac into a box. By default it only prints what it would do.
Run it
bash install.shDry run: checks the Mac and prints the plan. Changes nothing.
bash install.sh --applyMakes 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.
3d0ad8d36e70e688e04ad45750d693ca2b02d753c7c889122423da2547f2b330shasum -a 256 install.shWhat it does
-
Checks macOS and memory
macOS version, Apple silicon and memory. Read-only.
-
Checks FileVault and the firewall
If either is off, it says where to turn it on. Read-only.
-
Installs the AI runtime
Installs Ollama with Homebrew, for local use only. Asks first.
-
Downloads a model
Picks one that fits the memory and the free disk space. Asks first.
-
Sets up the document app
Makes it start with the Mac. Never overwrites anything. Asks first.
-
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 "$@"