From 2862f7844ce54b9aed75f20148b17a640472dcfd Mon Sep 17 00:00:00 2001
From: Ramon
Date: Mon, 27 Jul 2026 19:30:45 +0200
Subject: [PATCH] v0.7.30-beta - installatiescript voor Linux
curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh | sh
install.sh controleert het systeem, biedt aan Docker te installeren als dat
ontbreekt, kloont de repo (of pakt het tar-archief uit als git ontbreekt), maakt
.env aan, bouwt en start, wacht op /healthz en toont waar je terecht kunt met de
waarschuwing meteen een account aan te maken.
- Opties: --dir, --port, --bind, --branch, --token, --update, --uninstall,
--yes, --dry-run.
- --update haalt nieuwe code op en herstart; .env, stacks en gegevens blijven.
- --uninstall stopt de container maar laat gegevens staan, en vertelt hoe je
die alsnog opruimt.
- Gebruikt alleen sudo waar nodig: kun je zelf in de doelmap schrijven en
docker aanroepen, dan blijft alles onder je eigen account.
Tijdens het testen gevonden en verholpen:
- `docker compose --project-directory` zoekt zonder -f het compose-bestand in de
huidige map; bij `curl | sh` is dat je thuismap. Nu altijd -f erbij.
- De wachtlus gaf anderhalve minuut geen teken van leven; nu een punt per
poging en 90 in plaats van 120 seconden.
- Bij --dry-run braken ontbrekende Docker-onderdelen de voorvertoning af; nu
waarschuwingen zodat je de hele gang van zaken ziet.
- De versie-uitlezing uit /healthz tolereert nu ook json met spaties.
Documentatie: docs/installeren.md met alle opties, bijwerken, verwijderen en een
probleemoplostabel; README verwijst ernaar.
Tests: syntaxis onder sh/dash/bash, geen bashismen, --help noemt alle opties,
ongeldige invoer stopt, een proefdraai maakt niets aan, en de installatieregel
in README en docs is identiek.
958 tests groen.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
---
CHANGELOG.md | 38 ++++
README.md | 41 +++-
VERSION | 2 +-
docs/README.md | 1 +
docs/installeren.md | 149 +++++++++++++++
install.sh | 356 +++++++++++++++++++++++++++++++++++
tests/test_install_script.py | 111 +++++++++++
7 files changed, 693 insertions(+), 5 deletions(-)
create mode 100644 docs/installeren.md
create mode 100755 install.sh
create mode 100644 tests/test_install_script.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 055463d..7c9dc7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,41 @@
+# v0.7.30-beta — Installeren met één regel
+
+```bash
+curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh | sh
+```
+
+Het script controleert je systeem, biedt aan Docker te installeren als dat
+ontbreekt, haalt de code op, maakt `.env` aan, bouwt het image, start de
+container en wacht tot `/healthz` antwoordt. Daarna toont het waar je terecht
+kunt — met de waarschuwing dat je meteen een beheerdersaccount moet aanmaken.
+
+Opties: `--dir`, `--port`, `--bind`, `--branch`, `--token`, `--update`,
+`--uninstall`, `--yes` en `--dry-run`.
+
+**`--dry-run`** toont de hele gang van zaken zonder iets te wijzigen. Handig om
+te zien wat er gaat gebeuren voordat je een script met root-rechten loslaat op
+je server; `docs/installeren.md` zet die stap dan ook vooraan.
+
+**Bijwerken** gaat met `sh /opt/server-up/install.sh --update`: nieuwe code
+ophalen, opnieuw bouwen, herstarten. Je `.env`, stacks en gegevens blijven staan.
+**Verwijderen** stopt de container maar laat je gegevens met rust, en vertelt
+daarna hoe je die alsnog opruimt.
+
+Het script gebruikt alleen `sudo` waar dat nodig is: kun je zelf al in de
+doelmap schrijven én docker aanroepen, dan blijft alles onder je eigen account.
+
+Nieuw: `README.md` verwijst er nu naar, plus `docs/installeren.md` met alle
+opties en een probleemoplostabel.
+
+Getest onder `sh`, `dash` en `bash`, met een testsuite die controleert dat er
+geen bashismen in sluipen, dat een typefout in de opties netjes stopt, en dat
+een proefdraai echt niets aanmaakt. Twee dingen die daarbij naar boven kwamen:
+de wachtlus gaf anderhalve minuut lang geen enkel teken van leven (nu puntjes),
+en `docker compose --project-directory` zoekt zonder `-f` het compose-bestand
+alsnog in de huidige map — wat bij `curl | sh` je thuismap is.
+
+---
+
# v0.7.20-beta — Instellingen wijzigen, koppelen achteraf, en een defect uit v0.7.00
## 🔴 Gedeeld netwerk botste met `network_mode`
diff --git a/README.md b/README.md
index 136c257..4d1c47a 100644
--- a/README.md
+++ b/README.md
@@ -33,13 +33,35 @@ IP-adres in je netwerk geven.
## Installeren
```bash
-git clone https://github.com/bes-r/server-up.git
-cd server-up
-cp .env.example .env # instellingen; zie hieronder
+curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh | sh
+```
+
+Dat controleert je systeem, installeert Docker als het ontbreekt, zet Server Up
+in `/opt/server-up` en start het op `http://localhost:5000`.
+
+Liever eerst lezen wat je uitvoert — verstandig, want het script gebruikt
+root-rechten:
+
+```bash
+curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh -o install.sh
+less install.sh
+sh install.sh --dry-run # toont wat het zou doen, zonder iets te wijzigen
+sh install.sh
+```
+
+Handmatig kan ook:
+
+```bash
+git clone https://git.ramonbesselink.nl/bes-r/server-up.git /opt/server-up
+cd /opt/server-up
+cp .env.example .env
docker compose up -d --build
```
-Open daarna `http://localhost:5000` en **maak meteen een beheerdersaccount aan.**
+Alle opties staan in [docs/installeren.md](docs/installeren.md):
+`--dir`, `--port`, `--bind`, `--branch`, `--token`, `--update`, `--uninstall`.
+
+Open daarna de interface en **maak meteen een beheerdersaccount aan.**
Zolang dat niet gebeurd is, kan iedereen die de pagina bereikt het account
claimen — de container waarschuwt daar bij het opstarten ook voor.
@@ -60,6 +82,7 @@ proxy met TLS voor staat.
| Document | Waarover |
|---|---|
+| [docs/installeren.md](docs/installeren.md) | Het installatiescript, opties, bijwerken en verwijderen |
| [docs/beveiliging.md](docs/beveiliging.md) | Accounts, rollen, SSO, reverse proxy, wat wel en niet is afgedekt |
| [docs/apps-maken.md](docs/apps-maken.md) | Eigen apps toevoegen: het volledige templateformaat |
| [docs/netwerken.md](docs/netwerken.md) | Stacks een eigen IP geven met macvlan of ipvlan |
@@ -82,6 +105,16 @@ apps/mijn-app/
Het volledige formaat staat in [docs/apps-maken.md](docs/apps-maken.md).
+## Bijwerken en verwijderen
+
+```bash
+sh /opt/server-up/install.sh --update # nieuwste versie ophalen en herstarten
+sh /opt/server-up/install.sh --uninstall # stoppen; je gegevens blijven staan
+```
+
+Draait Server Up vanaf een registry-image, dan kan bijwerken ook met een knop in
+de interface — zie [docs/updates.md](docs/updates.md).
+
## Ontwikkelen
```bash
diff --git a/VERSION b/VERSION
index 944e688..1e3a4ac 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.7.20-beta
+0.7.30-beta
diff --git a/docs/README.md b/docs/README.md
index 5eeff25..b9f4876 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,6 +2,7 @@
| Document | Waarover, en wanneer je het nodig hebt |
|---|---|
+| [installeren.md](installeren.md) | Installeren met één regel, alle opties van het script, bijwerken en verwijderen. |
| [beveiliging.md](beveiliging.md) | Accounts, rollen, reverse-proxy-SSO en TLS. **Lees dit voordat je Server Up van buiten je netwerk bereikbaar maakt** — toegang tot de interface staat gelijk aan root op de host. |
| [apps-maken.md](apps-maken.md) | Eigen apps toevoegen: het volledige `template.json`-formaat, de compose-syntaxis, apps aan elkaar koppelen en onderdelen optioneel maken. |
| [netwerken.md](netwerken.md) | Stacks een eigen IP-adres geven in je LAN met macvlan of ipvlan. Inclusief de valkuil dat de host zijn eigen macvlan-containers niet bereikt. |
diff --git a/docs/installeren.md b/docs/installeren.md
new file mode 100644
index 0000000..a75e0d6
--- /dev/null
+++ b/docs/installeren.md
@@ -0,0 +1,149 @@
+# Installeren
+
+## In één regel
+
+```bash
+curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh | sh
+```
+
+Dat installeert Server Up in `/opt/server-up`, bereikbaar op
+`http://localhost:5000`.
+
+**Liever eerst zien wat je uitvoert?** Verstandig — je haalt een script binnen
+dat root-rechten gebruikt:
+
+```bash
+curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh -o install.sh
+less install.sh
+sh install.sh
+```
+
+Wil je alleen weten wát het zou doen, zonder dat er iets verandert:
+
+```bash
+sh install.sh --dry-run
+```
+
+---
+
+## Wat het script doet
+
+1. Controleert of je op Linux zit en of `curl` en `tar` aanwezig zijn
+2. Kijkt of Docker draait — zo niet, biedt het aan die te installeren via het
+ officiële script van docker.com (het vraagt eerst)
+3. Kloont de repo naar `/opt/server-up` (of pakt het tar-archief uit als git
+ ontbreekt)
+4. Maakt `.env` aan op basis van `.env.example`, met jouw poort en bind-adres
+5. Bouwt het image en start de container
+6. Wacht tot `/healthz` antwoordt en toont waar je terecht kunt
+
+Het gebruikt alleen `sudo` waar dat nodig is. Kun je zelf al in de doelmap
+schrijven én `docker` aanroepen, dan blijft alles onder je eigen account.
+
+---
+
+## Opties
+
+```
+--dir PAD Installatiemap (standaard: /opt/server-up)
+--port POORT Poort voor de webinterface (standaard: 5000)
+--bind ADRES Waarop de poort luistert (standaard: 127.0.0.1)
+--branch NAAM Branch om te installeren (standaard: main)
+--token TOKEN Toegangstoken, als je repo niet openbaar is
+--update Bijwerken naar de nieuwste versie
+--uninstall Stoppen en verwijderen (gegevens blijven staan)
+--yes Niets vragen
+--dry-run Alleen tonen wat er zou gebeuren
+```
+
+### Voorbeelden
+
+```bash
+# Standaard, alleen bereikbaar vanaf de server zelf
+sh install.sh
+
+# Bereikbaar op je netwerk, andere poort
+sh install.sh --bind 0.0.0.0 --port 8080
+
+# Ergens anders neerzetten
+sh install.sh --dir /srv/server-up
+
+# De beta-branch, zonder vragen
+sh install.sh --branch dev --yes
+
+# Uit een repo die niet openbaar is
+sh install.sh --token jouw-forgejo-token
+```
+
+---
+
+## Meteen na het installeren
+
+**Maak een beheerdersaccount aan.** Server Up beheert de Docker-daemon, en wie
+containers kan starten kan willekeurige mappen van de host mounten. Zolang er
+nog geen account is, kan iedereen die de pagina bereikt het claimen.
+
+De poort staat standaard op `127.0.0.1`, dus alleen bereikbaar vanaf de server
+zelf. Wil je er van buitenaf bij, zet dan eerst een reverse proxy met TLS ervoor
+en pas daarna `BIND` aan:
+
+```bash
+sed -i 's/^BIND=.*/BIND=0.0.0.0/' /opt/server-up/.env
+docker compose -f /opt/server-up/docker-compose.yml up -d
+```
+
+Zie [beveiliging.md](beveiliging.md) voor de reverse proxy, rollen en SSO.
+
+---
+
+## Bijwerken
+
+```bash
+sh /opt/server-up/install.sh --update
+```
+
+Haalt de nieuwste versie op, bouwt opnieuw en herstart. Je `.env` blijft staan,
+net als je stacks en gegevens.
+
+Draait Server Up vanaf een registry-image, dan kun je ook bijwerken vanuit de
+interface zelf — zie [updates.md](updates.md).
+
+---
+
+## Verwijderen
+
+```bash
+sh /opt/server-up/install.sh --uninstall
+```
+
+Stopt en verwijdert de container. **Je gegevens blijven staan**: het
+docker-volume `su-data` (instellingen, accounts, auditlog) en je stackmappen
+onder `/opt/serverup`. Het script vertelt daarna hoe je die alsnog opruimt.
+
+---
+
+## Handmatig, zonder script
+
+```bash
+git clone https://git.ramonbesselink.nl/bes-r/server-up.git /opt/server-up
+cd /opt/server-up
+cp .env.example .env
+$EDITOR .env
+docker compose up -d --build
+```
+
+---
+
+## Als er iets misgaat
+
+| Melding | Wat er aan de hand is |
+|---|---|
+| `Geen root en geen sudo` | Draai als root, of kies met `--dir` een map waar je zelf in mag schrijven |
+| `Docker is geïnstalleerd maar de daemon reageert niet` | `systemctl start docker` |
+| `De docker-compose-plugin ontbreekt` | Installeer `docker-compose-plugin` via je pakketbeheerder |
+| `Ophalen mislukt. Is de repo openbaar?` | Gebruik `--token` met een Forgejo-token dat de repo mag lezen |
+| Reageert niet binnen anderhalve minuut | `docker compose -f /opt/server-up/docker-compose.yml logs --tail=50` |
+
+Laadt de interface zonder opmaak, dan is het downloaden van de front-end-
+bestanden tijdens de build misgegaan. Server Up meldt dat bij het opstarten in
+zijn eigen log; opnieuw bouwen lost het meestal op.
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..8533f58
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,356 @@
+#!/bin/sh
+# Server Up — installeren, bijwerken of verwijderen op een Linux-server.
+#
+# curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh | sh
+#
+# Liever eerst lezen wat je uitvoert (aan te raden):
+#
+# curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/raw/branch/main/install.sh -o install.sh
+# less install.sh
+# sh install.sh
+#
+# POSIX sh, dus het werkt onder dash, ash en bash.
+
+set -eu
+
+SCRIPT_VERSIE="1.0"
+GIT_HOST="git.ramonbesselink.nl"
+GIT_PAD="bes-r/server-up"
+BRANCH="main"
+DOEL="/opt/server-up"
+POORT="5000"
+BIND="127.0.0.1"
+TOKEN=""
+JA=0
+DROOG=0
+ACTIE="install"
+
+# ── Uitvoer ──────────────────────────────────────────────────────────────────
+# Kleuren alleen als we naar een terminal schrijven; in een pipe of logbestand
+# leveren escape-codes onleesbare rommel op.
+if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
+ C_OK=$(printf '\033[32m'); C_WAARSCHUW=$(printf '\033[33m')
+ C_FOUT=$(printf '\033[31m'); C_KOP=$(printf '\033[1m'); C_UIT=$(printf '\033[0m')
+else
+ C_OK=''; C_WAARSCHUW=''; C_FOUT=''; C_KOP=''; C_UIT=''
+fi
+
+log() { printf '%s\n' " $*"; }
+stap() { printf '\n%s%s%s\n' "$C_KOP" "$*" "$C_UIT"; }
+goed() { printf ' %s✔%s %s\n' "$C_OK" "$C_UIT" "$*"; }
+waarschuw() { printf ' %s!%s %s\n' "$C_WAARSCHUW" "$C_UIT" "$*"; }
+fout() { printf '\n %s✖ %s%s\n\n' "$C_FOUT" "$*" "$C_UIT" >&2; exit 1; }
+# Bij --dry-run alleen waarschuwen: je wilt de hele voorvertoning zien, ook op
+# een machine waar Docker (nog) niet staat.
+fout_tenzij_droog() {
+ if [ "$DROOG" -eq 1 ]; then waarschuw "$*"; else fout "$*"; fi
+}
+
+heeft() { command -v "$1" >/dev/null 2>&1; }
+
+# Voert uit, of toont alleen wat er zou gebeuren bij --dry-run.
+doe() {
+ if [ "$DROOG" -eq 1 ]; then
+ printf ' [droog] %s\n' "$*"
+ else
+ "$@"
+ fi
+}
+
+vraag() {
+ # vraag "Vraag?" -> 0 bij ja. Met --yes of zonder terminal: altijd ja.
+ [ "$JA" -eq 1 ] && return 0
+ [ -t 0 ] || return 0
+ printf ' %s [J/n] ' "$1"
+ read -r antwoord /dev/null 2>&1; then
+ goed "Draait onder je eigen account; sudo is niet nodig"
+ return 0
+ fi
+ fi
+ if heeft sudo; then
+ SUDO="sudo"
+ log "sudo wordt gebruikt voor ${DOEL} en docker"
+ else
+ fout "Geen root en geen sudo. Draai als root, of kies met --dir een map waar je zelf in mag schrijven."
+ fi
+}
+
+# ── Vereisten ────────────────────────────────────────────────────────────────
+controleer_systeem() {
+ stap "Systeem controleren"
+ bepaal_rechten
+ [ "$(uname -s)" = "Linux" ] || waarschuw "Dit script is voor Linux; op $(uname -s) is het onbeproefd."
+ for hulp in curl tar; do
+ heeft "$hulp" || fout "'$hulp' ontbreekt. Installeer het en probeer opnieuw."
+ done
+ goed "$(uname -s) $(uname -m)"
+}
+
+controleer_docker() {
+ if heeft docker; then
+ if $SUDO docker info >/dev/null 2>&1; then
+ goed "Docker draait ($($SUDO docker version --format '{{.Server.Version}}' 2>/dev/null || echo '?'))"
+ else
+ fout_tenzij_droog "Docker is geïnstalleerd maar de daemon reageert niet. Start hem met: systemctl start docker"
+ fi
+ else
+ waarschuw "Docker is niet geïnstalleerd."
+ if vraag "Nu installeren via het officiële script van docker.com?"; then
+ stap "Docker installeren"
+ doe sh -c "curl -fsSL https://get.docker.com | $SUDO sh"
+ doe $SUDO systemctl enable --now docker || true
+ heeft docker || [ "$DROOG" -eq 1 ] || fout "Installeren van Docker is niet gelukt."
+ goed "Docker geïnstalleerd"
+ else
+ fout_tenzij_droog "Docker is vereist. Zie https://docs.docker.com/engine/install/"
+ fi
+ fi
+
+ if $SUDO docker compose version >/dev/null 2>&1; then
+ goed "Compose-plugin aanwezig"
+ else
+ fout_tenzij_droog "De docker-compose-plugin ontbreekt. Installeer 'docker-compose-plugin' via je pakketbeheerder."
+ fi
+}
+
+# ── Broncode ophalen ─────────────────────────────────────────────────────────
+bron_url() {
+ if [ -n "$TOKEN" ]; then
+ printf 'https://%s@%s/%s.git' "$TOKEN" "$GIT_HOST" "$GIT_PAD"
+ else
+ printf 'https://%s/%s.git' "$GIT_HOST" "$GIT_PAD"
+ fi
+}
+
+haal_broncode() {
+ stap "Broncode ophalen"
+ if [ -d "$DOEL/.git" ]; then
+ log "Bestaande installatie bijwerken in $DOEL"
+ doe $SUDO git -C "$DOEL" fetch --depth 1 origin "$BRANCH"
+ doe $SUDO git -C "$DOEL" reset --hard "origin/$BRANCH"
+ goed "Bijgewerkt naar de nieuwste $BRANCH"
+ return 0
+ fi
+
+ doe $SUDO mkdir -p "$DOEL"
+ if heeft git; then
+ # Zonder .git kunnen we later niet netjes bijwerken, dus git heeft de
+ # voorkeur boven een los archief.
+ if doe $SUDO git clone --depth 1 --branch "$BRANCH" "$(bron_url)" "$DOEL"; then
+ goed "Gekloond naar $DOEL"
+ return 0
+ fi
+ waarschuw "Klonen mislukte; ik probeer het archief."
+ fi
+
+ archief="https://${GIT_HOST}/${GIT_PAD}/archive/${BRANCH}.tar.gz"
+ log "Downloaden: $archief"
+ if [ "$DROOG" -eq 0 ]; then
+ tijdelijk="$(mktemp -d)"
+ curl -fsSL ${TOKEN:+-H "Authorization: token $TOKEN"} "$archief" \
+ | tar xz -C "$tijdelijk" --strip-components=1 \
+ || fout "Ophalen mislukt. Is de repo openbaar? Zo niet, gebruik --token."
+ $SUDO cp -r "$tijdelijk/." "$DOEL/"
+ rm -rf "$tijdelijk"
+ fi
+ goed "Uitgepakt naar $DOEL"
+}
+
+# ── Instellingen ─────────────────────────────────────────────────────────────
+schrijf_env() {
+ stap "Instellingen"
+ env_bestand="$DOEL/.env"
+ if [ -f "$env_bestand" ]; then
+ goed "Bestaande .env blijft ongemoeid"
+ # BIND en PORT wel bijwerken als ze expliciet zijn meegegeven.
+ return 0
+ fi
+ if [ "$DROOG" -eq 1 ]; then
+ printf ' [droog] .env aanmaken met BIND=%s PORT=%s\n' "$BIND" "$POORT"
+ return 0
+ fi
+ if [ -f "$DOEL/.env.example" ]; then
+ $SUDO cp "$DOEL/.env.example" "$env_bestand"
+ else
+ printf '' | $SUDO tee "$env_bestand" >/dev/null
+ fi
+ $SUDO sh -c "sed -i '/^BIND=/d;/^PORT=/d' '$env_bestand'"
+ $SUDO sh -c "printf 'BIND=%s\nPORT=%s\n' '$BIND' '$POORT' >> '$env_bestand'"
+ goed "Aangemaakt: $env_bestand (BIND=$BIND, PORT=$POORT)"
+}
+
+# ── Starten ──────────────────────────────────────────────────────────────────
+start() {
+ stap "Bouwen en starten"
+ if [ "$DROOG" -eq 0 ] && [ ! -f "$DOEL/docker-compose.yml" ]; then
+ fout "Geen docker-compose.yml in $DOEL — het ophalen van de broncode is misgegaan."
+ fi
+ log "Dit duurt de eerste keer enkele minuten."
+ doe $SUDO docker compose -f "$DOEL/docker-compose.yml" --project-directory "$DOEL" \
+ up -d --build --remove-orphans
+ goed "Container gestart"
+}
+
+wacht_op_gereed() {
+ [ "$DROOG" -eq 1 ] && return 0
+ stap "Wachten tot Server Up reageert"
+ printf ' '
+ i=0
+ while [ "$i" -lt 45 ]; do
+ if curl -fsS "http://127.0.0.1:${POORT}/healthz" >/dev/null 2>&1; then
+ # Spatie na de dubbele punt toestaan: of json compact is,
+ # hangt af van de Flask-versie.
+ versie="$(curl -fsS "http://127.0.0.1:${POORT}/healthz" 2>/dev/null \
+ | sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p')"
+ printf '\n'
+ goed "Server Up${versie:+ }${versie:-} draait"
+ return 0
+ fi
+ # Een puntje per poging, zodat je ziet dat er nog iets gebeurt.
+ printf '.'
+ i=$((i + 1))
+ sleep 2
+ done
+ printf '\n'
+ waarschuw "Server Up reageerde niet binnen anderhalve minuut."
+ log "Bekijk de logs met: $SUDO docker compose -f $DOEL/docker-compose.yml logs --tail=50"
+ return 1
+}
+
+afsluiting() {
+ if [ "$BIND" = "0.0.0.0" ]; then
+ ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
+ adres="http://${ip:-}:${POORT}"
+ else
+ adres="http://localhost:${POORT}"
+ fi
+
+ printf '\n%s────────────────────────────────────────────────────────%s\n' "$C_KOP" "$C_UIT"
+ printf '%s Server Up draait op %s%s\n' "$C_OK" "$adres" "$C_UIT"
+ printf '%s────────────────────────────────────────────────────────%s\n\n' "$C_KOP" "$C_UIT"
+
+ printf ' %sMaak nu meteen een beheerdersaccount aan.%s\n' "$C_WAARSCHUW" "$C_UIT"
+ printf ' Zolang dat niet gebeurd is, kan iedereen die deze pagina\n'
+ printf ' bereikt het account claimen.\n\n'
+
+ if [ "$BIND" = "127.0.0.1" ]; then
+ printf ' Alleen bereikbaar vanaf deze server zelf. Van buitenaf erbij?\n'
+ printf ' 1. Zet een reverse proxy met TLS ervoor\n'
+ printf ' 2. sed -i "s/^BIND=.*/BIND=0.0.0.0/" %s/.env\n' "$DOEL"
+ printf ' 3. %s docker compose -f %s/docker-compose.yml up -d\n\n' "${SUDO:-}" "$DOEL"
+ else
+ printf ' %sDe interface staat open op je netwerk. Server Up beheert%s\n' "$C_WAARSCHUW" "$C_UIT"
+ printf ' Docker als root, dus zet er een reverse proxy met TLS voor.\n\n'
+ fi
+
+ printf ' Bijwerken: sh %s/install.sh --update\n' "$DOEL"
+ printf ' Logs: %s docker compose -f %s/docker-compose.yml logs -f\n' "${SUDO:-}" "$DOEL"
+ printf ' Documentatie: %s/README.md\n\n' "$DOEL"
+}
+
+verwijderen() {
+ stap "Server Up verwijderen"
+ [ -d "$DOEL" ] || fout "Niets gevonden in $DOEL"
+ if ! vraag "Container stoppen en verwijderen? Je gegevens blijven staan."; then
+ log "Afgebroken."
+ exit 0
+ fi
+ doe $SUDO docker compose -f "$DOEL/docker-compose.yml" --project-directory "$DOEL" \
+ down --remove-orphans
+ goed "Container gestopt en verwijderd"
+ printf '\n'
+ log "Je gegevens staan nog in het docker-volume 'su-data' en in je stackmappen."
+ log "Alles definitief weg? Dan:"
+ log " $SUDO docker volume rm su-data"
+ log " $SUDO rm -rf $DOEL"
+ printf '\n'
+}
+
+# ── Hoofdprogramma ───────────────────────────────────────────────────────────
+printf '\n%sServer Up — installatiescript %s%s\n' "$C_KOP" "$SCRIPT_VERSIE" "$C_UIT"
+[ "$DROOG" -eq 1 ] && waarschuw "Proefdraai: er wordt niets gewijzigd."
+
+case "$ACTIE" in
+ uninstall)
+ bepaal_rechten
+ verwijderen
+ ;;
+ update)
+ [ -d "$DOEL" ] || fout "Geen installatie gevonden in $DOEL. Installeer eerst zonder --update."
+ controleer_systeem
+ controleer_docker
+ haal_broncode
+ start
+ wacht_op_gereed || true
+ printf '\n'
+ goed "Bijgewerkt."
+ ;;
+ install)
+ controleer_systeem
+ controleer_docker
+ haal_broncode
+ schrijf_env
+ start
+ wacht_op_gereed || true
+ afsluiting
+ ;;
+esac
diff --git a/tests/test_install_script.py b/tests/test_install_script.py
new file mode 100644
index 0000000..e64ef6b
--- /dev/null
+++ b/tests/test_install_script.py
@@ -0,0 +1,111 @@
+"""Het installatiescript: syntaxis, opties en de dingen die stuk mogen gaan.
+
+Een installer die je met `curl | sh` draait moet in elk geval syntactisch
+kloppen onder de shells die distributies als /bin/sh gebruiken, en niet
+stilzwijgend het verkeerde doen bij een typefout in de opties.
+"""
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+WORTEL = Path(__file__).resolve().parent.parent
+SCRIPT = WORTEL / "install.sh"
+
+
+def _draai(*args, **kw):
+ return subprocess.run(["sh", str(SCRIPT), *args], capture_output=True,
+ text=True, timeout=60, **kw)
+
+
+def test_script_bestaat_en_is_uitvoerbaar():
+ assert SCRIPT.is_file()
+ assert SCRIPT.stat().st_mode & 0o111, "niet uitvoerbaar"
+
+
+@pytest.mark.parametrize("shell", ["sh", "dash", "bash"])
+def test_syntaxis_klopt_onder(shell):
+ """Debian en Ubuntu gebruiken dash als /bin/sh; bashismen breken daar."""
+ if not shutil.which(shell):
+ pytest.skip(f"{shell} niet aanwezig")
+ r = subprocess.run([shell, "-n", str(SCRIPT)], capture_output=True, text=True)
+ assert r.returncode == 0, r.stderr
+
+
+def test_geen_bashismen():
+ tekst = SCRIPT.read_text(encoding="utf-8")
+ for patroon, uitleg in [
+ (r"\[\[", "[[ ]] is bash-only"),
+ (r"^\s*declare ", "declare is bash-only"),
+ (r"\$\{[A-Za-z_]+\[", "arrays zijn bash-only"),
+ (r"==", "== in test is bash-only"),
+ ]:
+ treffers = [l for l in tekst.splitlines()
+ if re.search(patroon, l) and not l.strip().startswith("#")]
+ assert not treffers, f"{uitleg}: {treffers[:2]}"
+
+
+def test_help_werkt_en_noemt_alle_opties():
+ r = _draai("--help")
+ assert r.returncode == 0
+ for optie in ("--dir", "--port", "--bind", "--branch", "--token",
+ "--update", "--uninstall", "--yes", "--dry-run"):
+ assert optie in r.stdout, f"{optie} niet gedocumenteerd in --help"
+
+
+def test_onbekende_optie_stopt():
+ r = _draai("--bestaatniet")
+ assert r.returncode != 0
+ assert "Onbekende optie" in r.stderr
+
+
+@pytest.mark.parametrize("poort", ["abc", "", "80a", "-1"])
+def test_ongeldige_poort_stopt(poort):
+ r = _draai("--port", poort)
+ assert r.returncode != 0
+ # Een lege waarde valt al af bij het uitlezen van de optie, de rest bij de
+ # controle daarna; beide meldingen gaan over de poort.
+ assert "poort" in r.stderr.lower() or "port" in r.stderr.lower()
+
+
+def test_update_zonder_installatie_zegt_dat(tmp_path):
+ r = _draai("--update", "--dir", str(tmp_path / "bestaatniet"))
+ assert r.returncode != 0
+ assert "Geen installatie gevonden" in r.stderr
+
+
+def test_dry_run_wijzigt_niets(tmp_path):
+ doel = tmp_path / "server-up"
+ r = _draai("--dry-run", "--dir", str(doel), "--yes")
+ assert not doel.exists(), "proefdraai heeft toch iets aangemaakt"
+ assert "[droog]" in r.stdout
+
+
+def test_dry_run_toont_de_hele_gang_van_zaken(tmp_path):
+ """Ook op een machine zonder Docker moet je de voorvertoning kunnen zien."""
+ r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes")
+ for kop in ("Systeem controleren", "Broncode ophalen", "Instellingen",
+ "Bouwen en starten"):
+ assert kop in r.stdout, f"stap ontbreekt: {kop}"
+
+
+def test_verwijst_naar_het_juiste_git_domein():
+ tekst = SCRIPT.read_text(encoding="utf-8")
+ assert 'GIT_HOST="git.ramonbesselink.nl"' in tekst
+
+
+def test_compose_wordt_met_expliciet_bestand_aangeroepen():
+ """`--project-directory` alleen laat compose het bestand nog in de huidige
+ map zoeken; met `curl | sh` is dat je thuismap."""
+ for regel in SCRIPT.read_text(encoding="utf-8").splitlines():
+ if "docker compose" in regel and ("up -d" in regel or "down " in regel):
+ assert "-f " in regel, f"geen expliciet compose-bestand: {regel.strip()}"
+
+
+def test_readme_en_docs_noemen_dezelfde_installatieregel():
+ regel = ("curl -fsSL https://git.ramonbesselink.nl/bes-r/server-up/"
+ "raw/branch/main/install.sh")
+ assert regel in (WORTEL / "README.md").read_text(encoding="utf-8")
+ assert regel in (WORTEL / "docs" / "installeren.md").read_text(encoding="utf-8")