#!/bin/sh # Deneb для Mac: установка и обновление одной командой (ADR-0027, DEN-93). # # curl -fsSL https://<адрес сервера>/install.sh | sh # # Что делает: проверяет Mac (Apple Silicon, macOS 14+), читает <адрес>/download/latest.json, # скачивает архив приложения, сверяет SHA-256, закрывает работающий Deneb, ставит Deneb.app в # «Программы» (/Applications; нет прав — ~/Applications) и запускает. Повторный запуск — обновление. # Без sudo. При любой ошибке прежняя установка остаётся как была. # # Файлы, скачанные curl, не получают карантин macOS — поэтому при первом запуске нет вопроса # «Всё равно открыть» (приложение не заверено у Apple, ADR-0020). # # Адрес сервера подставляет Caddy (шаблон) — тот, с которого скачан скрипт. # Переменные (для проверок и особых случаев): # DENEB_URL — адрес сервера вместо подставленного # DENEB_INSTALL_DIR — куда ставить вместо «Программ» (проверки: временная папка *.noindex) # DENEB_INSTALL_NO_OPEN=1 — не запускать после установки # DENEB_LANG=ru|en — язык сообщений (по умолчанию — язык macOS) # DENEB_TEST_ARCH, DENEB_TEST_MACOS — подменить архитектуру и версию macOS (только проверки) set -u APP_NAME="Deneb.app" BUNDLE_ID="com.denebhq.deneb-app" MIN_MACOS=14 DENEB_URL=${DENEB_URL:-'https://mydeneb.pro'} DENEB_URL=${DENEB_URL%/} # --- язык сообщений ------------------------------------------------------------------------- lang=${DENEB_LANG:-} if [ -z "$lang" ]; then first=$(defaults read -g AppleLanguages 2>/dev/null | sed -n '2s/[^A-Za-z-]//gp') case "${first:-${LANG:-}}" in ru* | RU*) lang=ru ;; *) lang=en ;; esac fi say() { # say "по-русски" "in English" if [ "$lang" = ru ]; then printf '%s\n' "$1"; else printf '%s\n' "$2"; fi } fail() { # fail "по-русски" "in English" if [ "$lang" = ru ]; then printf 'Deneb: %s\n' "$1" >&2; else printf 'Deneb: %s\n' "$2" >&2; fi exit 1 } tmp="" staged="" backup="" dest="" cleanup() { # Прервались между двумя переименованиями: вернуть прежнюю установку. if [ -n "$backup" ] && [ -e "$backup/$APP_NAME" ] && [ -n "$dest" ] && [ ! -e "$dest" ]; then mv "$backup/$APP_NAME" "$dest" || true fi [ -n "$staged" ] && [ -e "$staged" ] && rm -rf "$staged" [ -n "$backup" ] && [ -d "$backup" ] && rmdir "$backup" 2>/dev/null || true [ -n "$tmp" ] && [ -d "$tmp" ] && rm -rf "$tmp" return 0 } trap cleanup EXIT trap 'exit 1' INT TERM HUP # --- этот Mac ------------------------------------------------------------------------------- [ "$(uname -s)" = Darwin ] || fail "нужен Mac (macOS 14 или новее)." "a Mac is required (macOS 14 or later)." arch=${DENEB_TEST_ARCH:-} if [ -z "$arch" ]; then # uname -m в Терминале под Rosetta говорит x86_64 — спрашиваем само железо. if [ "$(sysctl -in hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; else arch=$(uname -m); fi fi [ "$arch" = arm64 ] || fail "Deneb работает только на Mac с процессором Apple (M1 и новее)." \ "Deneb runs only on Macs with Apple silicon (M1 or later)." macos=${DENEB_TEST_MACOS:-$(sw_vers -productVersion 2>/dev/null)} major=${macos%%.*} case "$major" in '' | *[!0-9]*) major=0 ;; esac [ "$major" -ge "$MIN_MACOS" ] || fail "нужна macOS $MIN_MACOS или новее (сейчас ${macos:-неизвестно})." \ "macOS $MIN_MACOS or later is required (this Mac has ${macos:-unknown})." for c in curl shasum tar plutil ditto; do command -v "$c" >/dev/null 2>&1 || fail "нет команды $c." "the $c command is missing." done # Только https; простой http — лишь для проверок на этом же Mac. check_url() { # check_url <адрес> case "$1" in https://*) ;; http://127.0.0.1:* | http://127.0.0.1/* | http://localhost:* | http://localhost/*) ;; *) return 1 ;; esac } check_url "$DENEB_URL/" || fail "адрес сервера не похож на https://…: $DENEB_URL" \ "the server address is not https://…: $DENEB_URL" fetch() { # fetch <адрес> <файл> case "$1" in https://*) curl -fsSL --retry 2 --connect-timeout 15 --proto '=https' --proto-redir '=https' -o "$2" "$1" ;; *) curl -fsS --retry 2 --connect-timeout 15 --proto '=http' -o "$2" "$1" ;; esac } tmp=$(mktemp -d "${TMPDIR:-/tmp}/deneb-install.XXXXXX") || fail "не удалось создать временную папку." \ "could not create a temporary folder." # --- последняя версия ----------------------------------------------------------------------- say "Deneb: ищу последнюю версию на ${DENEB_URL}…" "Deneb: looking for the latest version on ${DENEB_URL}…" fetch "$DENEB_URL/download/latest.json" "$tmp/latest.json" || fail "не удалось получить $DENEB_URL/download/latest.json — нет сети или выпуска ещё нет." \ "could not get $DENEB_URL/download/latest.json (no network, or nothing has been released yet)." field() { plutil -extract "$1" raw -o - "$tmp/latest.json" 2>/dev/null; } version=$(field version) || version="" url=$(field app_archive.url) || url="" sum=$(field app_archive.sha256) || sum="" [ -n "$version" ] && [ -n "$url" ] && [ -n "$sum" ] || fail "в latest.json нет версии или архива приложения." "latest.json has no version or app archive." check_url "$url" || fail "адрес архива не https: $url" "the archive address is not https: $url" sum=$(printf '%s' "$sum" | tr 'A-F' 'a-f') case "$sum" in *[!0-9a-f]* | '') fail "неверная контрольная сумма в latest.json." "bad checksum in latest.json." ;; esac [ "${#sum}" -eq 64 ] || fail "неверная контрольная сумма в latest.json." "bad checksum in latest.json." # --- скачать и проверить -------------------------------------------------------------------- say "Deneb $version: скачиваю…" "Deneb $version: downloading…" fetch "$url" "$tmp/app.tar.gz" || fail "не удалось скачать $url" "could not download $url" got=$(shasum -a 256 "$tmp/app.tar.gz" | cut -d ' ' -f 1) [ "$got" = "$sum" ] || fail "контрольная сумма не совпала — файл повреждён или подменён, ничего не установлено." \ "checksum mismatch: the file is damaged or was tampered with, nothing was installed." # Не разрешаем архиву писать за пределы временной папки или приносить второе приложение. tar -tzf "$tmp/app.tar.gz" >"$tmp/entries" 2>/dev/null || fail "не удалось прочитать архив." "could not read the archive." awk '/(^\/|(^|\/)\.\.(\/|$))/ {exit 1} !/^(.\/)?Deneb\.app(\/|$)/ {exit 1}' "$tmp/entries" || fail "архив содержит посторонние пути." "the archive contains unexpected paths." mkdir "$tmp/x" && tar -xzf "$tmp/app.tar.gz" -C "$tmp/x" 2>/dev/null || fail "не удалось распаковать архив." "could not unpack the archive." new="" for d in "$tmp"/x/*.app; do [ -d "$d" ] || continue [ -z "$new" ] || fail "в архиве больше одного приложения." "the archive holds more than one app." new=$d done [ -n "$new" ] && [ -f "$new/Contents/Info.plist" ] || fail "в архиве нет приложения." "the archive holds no app." id=$(plutil -extract CFBundleIdentifier raw -o - "$new/Contents/Info.plist" 2>/dev/null) || id="" [ "$id" = "$BUNDLE_ID" ] || fail "в архиве не то приложение ($id)." "the archive holds a different app ($id)." xattr -dr com.apple.quarantine "$new" 2>/dev/null || true # --- куда ставить --------------------------------------------------------------------------- if [ -n "${DENEB_INSTALL_DIR:-}" ]; then dir=$DENEB_INSTALL_DIR elif [ -d "$HOME/Applications/$APP_NAME" ] && [ ! -d "/Applications/$APP_NAME" ]; then dir=$HOME/Applications # уже стоит там — обновляем там же elif [ -w /Applications ] && { [ ! -e "/Applications/$APP_NAME" ] || [ -w "/Applications/$APP_NAME" ]; }; then dir=/Applications else dir=$HOME/Applications fi mkdir -p "$dir" 2>/dev/null && [ -w "$dir" ] || fail "нет прав на запись в $dir" "no permission to write to $dir" dest=$dir/$APP_NAME [ ! -e "$dest" ] || [ -w "$dest" ] || fail "нет прав заменить $dest (его ставил другой пользователь)." \ "no permission to replace $dest (another user installed it)." # Сначала копия рядом с местом установки (тот же диск — переименование мгновенное). staged=$(mktemp -d "$dir/.Deneb.new.XXXXXX") || fail "не удалось подготовить папку в $dir" "could not prepare a folder in $dir" ditto "$new" "$staged/$APP_NAME" || fail "не удалось скопировать приложение в $dir" "could not copy the app to $dir" # --- закрыть работающий Deneb (только тот, что стоит на этом месте) --------------------------- # Подключения Claude и Codex к Deneb (`deneb mcp serve`, `deneb hook`) — процессы клиентов ИИ: их не # закрываем и установку из-за них не останавливаем (работают со старой копией до перезапуска клиента). processes() { ps -axo pid=,command= | awk -v path="$dest/Contents/MacOS/" '{pid=$1; sub(/^[[:space:]]*[0-9]+[[:space:]]+/, ""); if (index($0,path)!=1) next; if ($0 ~ /\/deneb (mcp|hook)( |$)/) next; print pid}'; } running() { [ -n "$(processes)" ]; } if running; then say "Закрываю Deneb…" "Quitting Deneb…" osascript -e 'on run argv' -e 'tell application (item 1 of argv) to quit' -e 'end run' "$dest" >/dev/null 2>&1 || true n=0 while running && [ "$n" -lt 20 ]; do sleep 0.5 n=$((n + 1)) done if running; then for pid in $(processes); do kill -TERM "$pid" >/dev/null 2>&1 || true; done sleep 1 fi running && fail "Deneb не закрылся — закройте его и запустите команду снова." \ "Deneb did not quit. Quit it and run the command again." fi # --- поставить ------------------------------------------------------------------------------ if [ -e "$dest" ]; then backup=$(mktemp -d "$dir/.Deneb.old.XXXXXX") || fail "не удалось подготовить резервную папку." "could not prepare a backup folder." mv "$dest" "$backup/$APP_NAME" || fail "не удалось убрать прежнюю версию." "could not move the previous version aside." fi if ! mv "$staged/$APP_NAME" "$dest"; then [ -n "$backup" ] && mv "$backup/$APP_NAME" "$dest" fail "не удалось поставить новую версию, прежняя оставлена." "could not install the new version; the previous one is kept." fi touch "$dest" # Finder и Dock подхватят новую иконку if [ "${DENEB_INSTALL_NO_OPEN:-}" != 1 ]; then if ! open "$dest"; then mv "$dest" "$staged/$APP_NAME" || fail "не удалось запустить приложение; резервная копия: $backup" \ "could not launch the app; backup: $backup" [ -n "$backup" ] && mv "$backup/$APP_NAME" "$dest" fail "не удалось запустить приложение, установка отменена; прежняя версия сохранена." \ "could not launch the app; installation canceled and the previous version kept." fi fi [ -n "$backup" ] && rm -rf "$backup" backup="" say "Готово: Deneb $version — $dest" "Done: Deneb $version is in $dest" if [ "${DENEB_INSTALL_NO_OPEN:-}" != 1 ]; then say "Deneb открыт. Войдите логином и паролем от администратора сервера." \ "Deneb is open. Sign in with the login and password from your server admin." fi