#!/bin/bash # # create-stick.sh — natives macOS-Terminal-Tool fuer bootfaehige # Installer-Sticks. Laeuft NUR auf echtem macOS. Nutzt ausschliesslich # Bordmittel: softwareupdate, diskutil, PlistBuddy, installer, asr, # createinstallmedia. Kein Homebrew, keine externen Abhaengigkeiten. # # Zwei moegliche QUELLEN fuer den Installer-Payload: # # 1) Apple-Server (Download) # softwareupdate --fetch-full-installer laedt die komplette # "Install macOS X.app" direkt von Apple. Danach normaler # createinstallmedia-Weg. Zukunftssicher: die Versionsliste kommt # live von Apples eigenen Servern, neue macOS-Versionen tauchen # automatisch auf. # # 2) Lokale Downloads-Bibliothek des Docker/Linux-Tools # (../downloads/library relativ zu diesem Script, wenn das ganze # Projekt inkl. Downloads z.B. per externer Platte auf den Mac # kopiert wurde). Kein Internet noetig, kein erneuter Download. # Zwei Payload-Formate stecken dort je nach macOS-Version, beide # werden hier unterstuetzt: # - InstallAssistant.pkg (Big Sur+): 'installer -pkg ... -target /' # entpackt daraus die komplette Install-App (inkl. # createinstallmedia-Binary) nach /Applications -- danach der # normale createinstallmedia-Weg wie bei Quelle 1. # - BaseSystem.dmg/InstallESD.dmg (bis Catalina): das ist schon # ein fertiges, bootfaehiges Volume. createinstallmedia braucht # es nicht extra -- Apples eigenes 'asr restore' schreibt es # direkt auf den Stick. Kein App-Download noetig. # # So wird aus jeder bereits vom Docker-Tool gebauten Offline-Bibliothek # (externe Platte, ohne Internet) auch am echten Mac ein bootfaehiger # Stick, ganz ohne doppelten Download. set -u # ---------- Darstellung ---------- BOLD="$(tput bold 2>/dev/null || true)" RED="$(tput setaf 1 2>/dev/null || true)" GREEN="$(tput setaf 2 2>/dev/null || true)" YELLOW="$(tput setaf 3 2>/dev/null || true)" CYAN="$(tput setaf 6 2>/dev/null || true)" RESET="$(tput sgr0 2>/dev/null || true)" hr() { printf '%s\n' "----------------------------------------------------------------------"; } title() { clear; hr; printf "%s%s%s\n" "$BOLD$CYAN" " $1" "$RESET"; hr; echo; } err() { printf "%s%s%s\n" "$RED" "FEHLER: $1" "$RESET" >&2; } ok() { printf "%s%s%s\n" "$GREEN" "$1" "$RESET"; } warn() { printf "%s%s%s\n" "$YELLOW" "$1" "$RESET"; } # Sehr simpler Flat-JSON-Feld-Extraktor -- reicht fuer die meta.json-Dateien # der Bibliothek (keine verschachtelten Objekte), spart eine Abhaengigkeit # zu jq/python3 die auf einem frischen Mac nicht garantiert vorhanden ist. json_field() { local file="$1" key="$2" sed -n 's/.*"'"$key"'":[[:space:]]*"\{0,1\}\([^",}]*\)"\{0,1\}.*/\1/p' "$file" | head -n1 } # ---------- Vorpruefungen ---------- if [[ "$(uname)" != "Darwin" ]]; then err "Dieses Script laeuft nur auf echtem macOS (kein Linux, keine VM ohne macOS)." exit 1 fi # ---------- Schritt 1: Quelle + Version waehlen ---------- title "macOS USB-Stick-Creator (nativ) — Schritt 1/4: Quelle + Version" echo "Woher soll der macOS-Installer kommen?" echo echo " ${BOLD}1)${RESET} Von Apple herunterladen (braucht Internet, mehrere GB)" echo " ${BOLD}2)${RESET} Aus lokaler Downloads-Bibliothek (Docker-Tool) verwenden -- kein Internet noetig" echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Quelle waehlen [Nummer]: " SRC_CHOICE if [[ "$SRC_CHOICE" == "0" || -z "$SRC_CHOICE" ]]; then echo "Abgebrochen." exit 0 fi SOURCE="" WRITE_MODE="" # "cim" (createinstallmedia) oder "asr" (direktes Image-Restore) APP_PATH="" DMG_PATH="" LIB_TITLE="" LIB_VERSION="" case "$SRC_CHOICE" in 1) SOURCE="apple" ;; 2) SOURCE="library" ;; *) err "Ungueltige Auswahl." exit 1 ;; esac if [[ "$SOURCE" == "apple" ]]; then if ! command -v softwareupdate >/dev/null 2>&1; then err "'softwareupdate' nicht gefunden — ungewoehnliches macOS-Setup, Abbruch." exit 1 fi MACOS_VER="$(sw_vers -productVersion)" # fetch-full-installer gibt es erst ab Catalina (10.15) if [[ "$MACOS_VER" == 10.* ]]; then MINOR="${MACOS_VER#10.}" MINOR="${MINOR%%.*}" if [[ "$MINOR" -lt 15 ]]; then err "macOS ${MACOS_VER} erkannt — 'softwareupdate --fetch-full-installer' braucht mind. Catalina (10.15)." exit 1 fi fi echo echo "Frage Apples Software-Update-Server nach verfuegbaren kompletten Installern ab..." echo LIST_RAW="$(softwareupdate --list-full-installers 2>/dev/null)" if [[ -z "$LIST_RAW" ]] || ! grep -q "Title:" <<<"$LIST_RAW"; then err "Keine Installer gefunden. Internetverbindung pruefen, oder Apple hat gerade ein Problem." exit 1 fi # Zeilenformat: "* Title: macOS Sonoma, Version: 14.6.1, Size: 12345678900, Build: 23G93" declare -a TITLES=() declare -a VERSIONS=() while IFS= read -r line; do [[ "$line" == *"Title:"* ]] || continue t="$(sed -E 's/^\* Title: ([^,]+),.*/\1/' <<<"$line")" v="$(sed -E 's/.*Version: ([^,]+),.*/\1/' <<<"$line")" TITLES+=("$t") VERSIONS+=("$v") done <<<"$LIST_RAW" if [[ ${#TITLES[@]} -eq 0 ]]; then err "Konnte die Liste nicht parsen. Rohausgabe:" echo "$LIST_RAW" exit 1 fi echo "Verfuegbar:" echo for i in "${!TITLES[@]}"; do printf " %s%2d)%s %s (%s)\n" "$BOLD" "$((i+1))" "$RESET" "${TITLES[$i]}" "${VERSIONS[$i]}" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Welche Version soll heruntergeladen/benutzt werden? [Nummer]: " CHOICE if [[ "$CHOICE" == "0" || -z "$CHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#TITLES[@]} )); then err "Ungueltige Auswahl." exit 1 fi SEL_TITLE="${TITLES[$((CHOICE-1))]}" SEL_VERSION="${VERSIONS[$((CHOICE-1))]}" WRITE_MODE="cim" # Apples Titel-Feld liefert z.B. "macOS Sonoma" -> App heisst "Install macOS Sonoma.app" APP_PATH="/Applications/Install ${SEL_TITLE}.app" echo ok "Gewaehlt: ${SEL_TITLE} (${SEL_VERSION}) -- Quelle: Apple-Server" else echo DEFAULT_LIB_DIR="$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/downloads/library" echo "Pfad zur lokalen Downloads-Bibliothek des Docker-Tools (Ordner mit einem" echo "Unterordner pro macOS-Version, je mit meta.json + Installer-Datei)." echo read -r -p "Pfad [Enter fuer ${DEFAULT_LIB_DIR}]: " LIB_DIR LIB_DIR="${LIB_DIR:-$DEFAULT_LIB_DIR}" if [[ ! -d "$LIB_DIR" ]]; then err "Ordner nicht gefunden: ${LIB_DIR}" exit 1 fi declare -a LIB_ENTRY_DIRS=() declare -a LIB_ENTRY_DESC=() for entry_dir in "$LIB_DIR"/*/; do [[ -d "$entry_dir" ]] || continue meta="${entry_dir}meta.json" [[ -f "$meta" ]] || continue complete="$(json_field "$meta" complete)" [[ "$complete" == "true" ]] || continue et="$(json_field "$meta" title)" ev="$(json_field "$meta" version)" emode="$(json_field "$meta" mode)" esize="$(json_field "$meta" size_bytes)" esize_gb="?" if [[ "$esize" =~ ^[0-9]+$ ]]; then esize_gb="$(( esize / 1000 / 1000 / 1000 ))" fi mode_desc="bis Catalina, robust" [[ "$emode" == "recovery" ]] && mode_desc="Big Sur+, ueber installer-pkg" LIB_ENTRY_DIRS+=("$entry_dir") LIB_ENTRY_DESC+=("${et} (${ev}) -- ~${esize_gb} GB, ${mode_desc}") done if [[ ${#LIB_ENTRY_DIRS[@]} -eq 0 ]]; then err "Keine vollstaendigen Installer in ${LIB_DIR} gefunden." exit 1 fi echo echo "In der Bibliothek gefunden:" echo for i in "${!LIB_ENTRY_DIRS[@]}"; do printf " %s%2d)%s %s\n" "$BOLD" "$((i+1))" "$RESET" "${LIB_ENTRY_DESC[$i]}" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo read -r -p "Welche Version verwenden? [Nummer]: " LCHOICE if [[ "$LCHOICE" == "0" || -z "$LCHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$LCHOICE" =~ ^[0-9]+$ ]] || (( LCHOICE < 1 || LCHOICE > ${#LIB_ENTRY_DIRS[@]} )); then err "Ungueltige Auswahl." exit 1 fi SEL_ENTRY_DIR="${LIB_ENTRY_DIRS[$((LCHOICE-1))]}" SEL_META="${SEL_ENTRY_DIR}meta.json" LIB_TITLE="$(json_field "$SEL_META" title)" LIB_VERSION="$(json_field "$SEL_META" version)" LIB_MODE="$(json_field "$SEL_META" mode)" LIB_FILENAME="$(json_field "$SEL_META" filename)" LIB_PAYLOAD_PATH="${SEL_ENTRY_DIR}${LIB_FILENAME}" if [[ ! -f "$LIB_PAYLOAD_PATH" ]]; then err "Payload-Datei nicht gefunden: ${LIB_PAYLOAD_PATH}" exit 1 fi if [[ "$LIB_MODE" == "recovery" ]]; then WRITE_MODE="cim" else WRITE_MODE="asr" DMG_PATH="$LIB_PAYLOAD_PATH" fi echo ok "Gewaehlt: ${LIB_TITLE} (${LIB_VERSION}) -- Quelle: lokale Bibliothek (${LIB_PAYLOAD_PATH})" fi # ---------- Schritt 2: Installer-Payload besorgen ---------- title "Schritt 2/4: Installer-Payload besorgen" if [[ "$SOURCE" == "apple" ]]; then if [[ -d "$APP_PATH" ]]; then ok "'${APP_PATH}' existiert bereits — Download wird uebersprungen." else warn "Das laedt mehrere GB von Apple und braucht sudo-Rechte." echo "Zielort: ${APP_PATH}" read -r -p "Download jetzt starten? [j/N]: " GO if [[ ! "$GO" =~ ^[jJ]$ ]]; then echo "Abgebrochen." exit 0 fi echo echo "Starte Download (kann je nach Version 30-90 Minuten dauern)..." sudo softwareupdate --fetch-full-installer --full-installer-version "$SEL_VERSION" RC=$? if [[ $RC -ne 0 || ! -d "$APP_PATH" ]]; then err "Download fehlgeschlagen oder App nicht am erwarteten Ort. Erwartet: ${APP_PATH}" echo "Tipp: 'ls /Applications | grep Install' zeigt den tatsaechlichen Namen." exit 1 fi ok "Download abgeschlossen: ${APP_PATH}" fi elif [[ "$WRITE_MODE" == "cim" ]]; then # Bibliothek, Big Sur+: InstallAssistant.pkg -> komplette Install-App # nach /Applications entpacken (Apples Standardverhalten von 'installer'). if [[ -d "$APP_PATH" ]]; then ok "'${APP_PATH}' existiert bereits — Installer-Lauf wird uebersprungen." else echo "Entpacke ${LIB_PAYLOAD_PATH} nach /Applications (installer -pkg, braucht sudo)..." echo "Das dauert je nach Groesse ein paar Minuten." echo sudo installer -pkg "$LIB_PAYLOAD_PATH" -target / RC=$? if [[ $RC -ne 0 ]]; then err "'installer -pkg' fehlgeschlagen ($RC)." exit 1 fi if [[ ! -d "$APP_PATH" ]]; then # Fallback: Apples interner App-Name wich vom Bibliotheks-Titel ab -- # nimm das zuletzt angelegte "Install macOS *.app"-Bundle. FALLBACK_APP="$(ls -dt /Applications/Install\ macOS*.app 2>/dev/null | head -n1)" [[ -n "$FALLBACK_APP" ]] && APP_PATH="$FALLBACK_APP" fi if [[ ! -d "$APP_PATH" ]]; then err "Konnte die installierte App nach 'installer -pkg' nicht finden." echo "Tipp: 'ls /Applications | grep Install' zeigt was tatsaechlich da ist." exit 1 fi ok "Installiert: ${APP_PATH}" fi else ok "Bibliothek liefert bereits ein fertiges, bootfaehiges Image (${DMG_PATH}) -- kein App-Download noetig." fi if [[ "$WRITE_MODE" == "cim" ]]; then CIM_BIN="${APP_PATH}/Contents/Resources/createinstallmedia" if [[ ! -x "$CIM_BIN" ]]; then err "createinstallmedia nicht gefunden unter: ${CIM_BIN}" exit 1 fi fi # ---------- Schritt 3: Ziel-USB-Stick waehlen ---------- title "Schritt 3/4: USB-Stick auswaehlen" echo "Externe, physische Laufwerke (nur ganze Platten, keine Partitionen):" echo PLIST_TMP="$(mktemp /tmp/diskutil_ext.XXXXXX.plist)" diskutil list -plist external physical > "$PLIST_TMP" 2>/dev/null declare -a DISKS=() declare -a DISK_DESC=() i=0 while :; do DID="$(/usr/libexec/PlistBuddy -c "Print :AllDisksAndPartitions:${i}:DeviceIdentifier" "$PLIST_TMP" 2>/dev/null)" [[ -z "$DID" ]] && break INFO="$(diskutil info -plist "$DID" 2>/dev/null)" NAME="$(echo "$INFO" | /usr/libexec/PlistBuddy -c "Print :MediaName" /dev/stdin 2>/dev/null)" SIZE_BYTES="$(echo "$INFO" | /usr/libexec/PlistBuddy -c "Print :TotalSize" /dev/stdin 2>/dev/null)" SIZE_GB="?" if [[ "$SIZE_BYTES" =~ ^[0-9]+$ ]]; then SIZE_GB="$(( SIZE_BYTES / 1000 / 1000 / 1000 ))" fi DISKS+=("$DID") DISK_DESC+=("/dev/${DID} — ${NAME:-unbekannt} (~${SIZE_GB} GB)") i=$((i+1)) done rm -f "$PLIST_TMP" if [[ ${#DISKS[@]} -eq 0 ]]; then err "Keine externen physischen Laufwerke gefunden. Stick eingesteckt?" exit 1 fi for i in "${!DISKS[@]}"; do printf " %s%2d)%s %s\n" "$BOLD" "$((i+1))" "$RESET" "${DISK_DESC[$i]}" done echo printf "%s0)%s Abbrechen\n" "$BOLD" "$RESET" echo warn "ACHTUNG: Der gewaehlte Stick wird KOMPLETT GELOESCHT." echo read -r -p "Welcher Stick? [Nummer]: " DCHOICE if [[ "$DCHOICE" == "0" || -z "$DCHOICE" ]]; then echo "Abgebrochen." exit 0 fi if ! [[ "$DCHOICE" =~ ^[0-9]+$ ]] || (( DCHOICE < 1 || DCHOICE > ${#DISKS[@]} )); then err "Ungueltige Auswahl." exit 1 fi TARGET_DISK="${DISKS[$((DCHOICE-1))]}" TARGET_DESC="${DISK_DESC[$((DCHOICE-1))]}" echo warn "Letzte Sicherheitsabfrage — dieser Stick wird geloescht:" echo " ${TARGET_DESC}" echo read -r -p "Tippe exakt '/dev/${TARGET_DISK}' zum Bestaetigen: " CONFIRM if [[ "$CONFIRM" != "/dev/${TARGET_DISK}" ]]; then err "Eingabe stimmt nicht ueberein — abgebrochen, nichts wurde veraendert." exit 1 fi # ---------- Schritt 4: Stick schreiben ---------- title "Schritt 4/4: Stick wird erstellt" if [[ "$WRITE_MODE" == "cim" ]]; then echo "App: ${APP_PATH}" echo "Ziel: /dev/${TARGET_DISK}" echo echo "createinstallmedia laeuft jetzt (braucht sudo, dauert typischerweise 10-30 Minuten)..." echo "Der Stick wird zwischendurch umbenannt/neu gemountet — das ist normal." echo sudo "$CIM_BIN" --volume "/dev/${TARGET_DISK}" --nointeraction RC=$? echo if [[ $RC -eq 0 ]]; then ok "Fertig! Der Stick ist bootfaehig." echo "Am Ziel-Mac: beim Start Option/Alt gedrueckt halten, den Stick im Boot-Picker waehlen." else err "createinstallmedia ist mit Fehlercode ${RC} beendet — siehe Ausgabe oben." exit $RC fi echo read -r -p "Die installierte App (mehrere GB) jetzt wieder loeschen um Platz zu sparen? [j/N]: " CLEAN if [[ "$CLEAN" =~ ^[jJ]$ ]]; then sudo rm -rf "$APP_PATH" ok "Geloescht: ${APP_PATH}" fi else # asr-Pfad: BaseSystem.dmg/InstallESD.dmg ist schon ein fertiges, # bootfaehiges Volume. Erst Stick neu formatieren (liefert einen # Mountpunkt), dann Apples 'asr restore' das eigentliche Image drauf # kopieren lassen -- das re-formatiert dabei nochmal komplett nach dem # Schema des Quell-Images (--erase). Etablierte Community-Technik fuer # macOS-Installer bis Catalina ohne createinstallmedia. echo "Quelle: ${DMG_PATH}" echo "Ziel: /dev/${TARGET_DISK}" echo echo "Formatiere /dev/${TARGET_DISK} neu (HFS+) als Vorbereitung (braucht sudo)..." sudo diskutil eraseDisk JHFS+ INSTALL "/dev/${TARGET_DISK}" if [[ $? -ne 0 ]]; then err "diskutil eraseDisk fehlgeschlagen." exit 1 fi VOL_MOUNT="/Volumes/INSTALL" if [[ ! -d "$VOL_MOUNT" ]]; then err "Erwarteter Mountpunkt ${VOL_MOUNT} nicht gefunden nach dem Formatieren." echo "Tipp: 'diskutil list /dev/${TARGET_DISK}' zeigt den tatsaechlichen Zustand." exit 1 fi echo echo "asr restore laeuft jetzt (braucht sudo, dauert typischerweise 10-20 Minuten)..." echo "Der Stick wird dabei nochmal komplett neu formatiert -- das ist normal." echo sudo asr restore --source "$DMG_PATH" --target "$VOL_MOUNT" --erase --noprompt RC=$? echo if [[ $RC -eq 0 ]]; then ok "Fertig! Der Stick ist bootfaehig." echo "Am Ziel-Mac: beim Start Option/Alt gedrueckt halten, den Stick im Boot-Picker waehlen." else err "asr restore ist mit Fehlercode ${RC} beendet — siehe Ausgabe oben." exit $RC fi fi echo ok "Alles erledigt."