#!/bin/sh # Install a checked beta on Apple silicon. No repository access or extra tools required. set -eu # From the app's Update (app-updates design D4): OPENSPEC_INSTALL_REPORT names a file this run reports to # (ready, done, or failed with why and whether before or after the app quit). Unset, as from the install # command, nothing changes. report_file=${OPENSPEC_INSTALL_REPORT:-} report() { if [ -n "$report_file" ]; then printf '%s\n' "$1" > "$report_file.tmp" && mv -f "$report_file.tmp" "$report_file"; last_report=$1; fi; } last_report= fail() { echo "OpenSpec Desktop: $*" >&2 report "{\"state\":\"failed\",\"stage\":\"$stage_name\",\"reason\":\"$(printf '%s' "$*" | tr -d '"\\')\"}" exit 1 } stage_name=before valid_version() { case "$1" in ''|*[!0-9a-z.-]*) return 1 ;; esac printf '%s\n' "$1" | grep -Eq '^((0|[1-9][0-9]*)\.){2}(0|[1-9][0-9]*)-beta\.[1-9][0-9]*$|^0\.0\.0-beta\.0\.dry\.[1-9][0-9]*\.[1-9][0-9]*$' } fetch() { curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-time 600 --retry 2 "$1" -o "$2"; } field() { plutil -extract "$1" raw -expect "$2" "$work/manifest.json"; } admin() { if [ -n "$sudo_cmd" ]; then sudo "$@"; else "$@"; fi; } cleanup() { code=$? trap - EXIT HUP INT TERM # A failed replacement restores the old bundle. User data is never moved or removed. if [ "$replacing" = yes ] && [ -d "$stage/previous.app" ]; then admin rm -rf "$target" admin mv "$stage/previous.app" "$target" || echo "Previous app kept at $stage/previous.app" >&2 fi # A step that stopped the run without `fail` (a tool's own error) still tells the app. case "$last_report" in *'"failed"'*) ;; *) [ "$code" -eq 0 ] || report "{\"state\":\"failed\",\"stage\":\"$stage_name\",\"reason\":\"The update stopped (exit $code).\"}" ;; esac # From the app, which has quit: whatever happened, it starts again, the new version or the one put back. if [ -n "$report_file" ] && [ "$stage_name" = after ] && [ "$code" -ne 0 ] && [ -d "$target" ]; then open "$target" || true; fi if [ -n "$stage" ] && [ ! -d "$stage/previous.app" ]; then admin rm -rf "$stage"; fi rm -rf "$work" exit "$code" } [ "$(uname -s)" = Darwin ] || fail 'This command is for macOS. On Windows: irm https://openspec.dev/install.ps1 | iex' [ "$(sysctl -n hw.optional.arm64 2>/dev/null || echo 0)" = 1 ] || fail 'This beta supports Apple silicon only; Intel Macs are not supported.' [ "$(sw_vers -productVersion | cut -d. -f1)" -ge 13 ] || fail 'macOS 13 or later is required.' [ "$#" -le 1 ] || fail 'Use the install command with at most one version argument.' base=${OPENSPEC_INSTALL_BASE:-https://downloads.openspec.dev} base=${base%/} case "$base" in *[!A-Za-z0-9.:/_~-]*) fail 'Invalid HTTPS download address.' ;; esac printf '%s\n' "$base" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~-]+)*$' || fail 'OPENSPEC_INSTALL_BASE must be an HTTPS download address, with no query or fragment.' version=${1:-} [ -z "$version" ] || valid_version "$version" || fail 'Choose a beta version, such as 0.1.0-beta.1.' work=$(mktemp -d "${TMPDIR:-/tmp}/openspec-install.XXXXXX") stage=''; sudo_cmd=''; replacing=no trap cleanup EXIT trap 'exit 1' HUP INT TERM # Its process, so an application started again meanwhile follows this run instead of starting another. report "{\"state\":\"downloading\",\"pid\":$$,\"version\":\"$version\",\"at\":$(date +%s)}" if [ -n "$version" ]; then fetch "$base/desktop/$version/manifest.json" "$work/manifest.json" else fetch "$base/desktop/latest.json" "$work/manifest.json" fi [ "$(field schema integer)" = 1 ] || fail 'Unsupported release record.' selected=$(field version string) valid_version "$selected" || fail 'Invalid release version.' [ -z "$version" ] || [ "$version" = "$selected" ] || fail 'The release record names a different version.' version=$selected file=$(field mac.file string) [ "$file" = "openspec-desktop-$version-mac-arm64.zip" ] || fail 'Invalid Mac release filename.' sha=$(field mac.sha256 string) [ "${#sha}" -eq 64 ] && printf '%s\n' "$sha" | grep -Eq '^[a-f0-9]{64}$' || fail 'Invalid release checksum.' field mac.signed bool >/dev/null fetch "$base/desktop/$version/$file" "$work/app.zip" [ "$(shasum -a 256 "$work/app.zip" | cut -d' ' -f1)" = "$sha" ] || fail 'Download checksum mismatch; the existing app was kept.' ditto -x -k "$work/app.zip" "$work/unpacked" candidate="$work/unpacked/OpenSpec Desktop.app" [ -d "$candidate" ] || fail 'The archive contains no OpenSpec Desktop app.' [ "$(plutil -extract CFBundleIdentifier raw -expect string "$candidate/Contents/Info.plist")" = dev.openspec.desktop ] || fail 'Invalid application identity.' [ "$(plutil -extract CFBundleShortVersionString raw -expect string "$candidate/Contents/Info.plist")" = "$version" ] || fail 'Application and release versions differ.' codesign --verify --deep --strict "$candidate" # Stage on the destination volume before quitting, so the last moves don't copy a large bundle. target='/Applications/OpenSpec Desktop.app' if [ ! -w /Applications ]; then sudo_cmd=sudo sudo -v < /dev/tty || fail 'Permission to install in /Applications is required.' fi stage=$(admin mktemp -d /Applications/.openspec-install.XXXXXX) admin ditto "$candidate" "$stage/new.app" # The app quits by itself once it reads this; the quit below then finds nothing, or a copy still saving. report "{\"state\":\"ready\",\"pid\":$$,\"version\":\"$version\",\"at\":$(date +%s)}" stage_name=after # AppleScript resolves the application even in a false branch; a first install has no registered app. # -a: Update runs this script from the app, and pgrep otherwise skips its own ancestors. if pgrep -ax 'OpenSpec Desktop' >/dev/null; then osascript -e 'tell application id "dev.openspec.desktop" to quit' fi # AppleScript's quit may return before the process has finished saving. i=0 while pgrep -ax 'OpenSpec Desktop' >/dev/null; do i=$((i + 1)); [ "$i" -le 30 ] || fail 'OpenSpec Desktop has not quit. Quit it, then run the command again.' sleep 1 done replacing=yes [ ! -e "$target" ] || admin mv "$target" "$stage/previous.app" admin mv "$stage/new.app" "$target" codesign --verify --deep --strict "$target" replacing=no # Done before the new version starts: it reads this file at launch, and must not take the run for one under way. report "{\"state\":\"done\",\"version\":\"$version\"}" open "$target" admin rm -rf "$stage" stage='' echo "Installed OpenSpec Desktop $version in /Applications." echo 'If macOS asks for your keychain password, enter it and choose Always Allow.' echo 'To update: curl -fsSL https://openspec.dev/install.sh | sh'