#!/usr/bin/env bash set -euo pipefail APP_NAME="cmux STAGING" BUNDLE_ID="com.cmuxterm.app.staging" BASE_APP_NAME="cmux" DERIVED_DATA="" NAME_SET=0 BUNDLE_SET=0 DERIVED_SET=0 TAG="" usage() { cat <<'EOF' Usage: ./scripts/reloads.sh [options] Release build with isolated "cmux STAGING" identity. Runs side-by-side with the production cmux app. Options: --tag Short tag for parallel builds (e.g., feature-xyz-lol). Sets app name, bundle id, and derived data path unless overridden. --name Override app display/bundle name. --bundle-id Override bundle identifier. --derived-data Override derived data path. -h, --help Show this help. EOF } sanitize_bundle() { local raw="$1" local cleaned cleaned="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/./g; s/^\\.+//; s/\\.+$//; s/\\.+/./g')" if [[ -z "$cleaned" ]]; then cleaned="agent" fi echo "$cleaned" } sanitize_path() { local raw="$1" local cleaned cleaned="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')" if [[ -z "$cleaned" ]]; then cleaned="agent" fi echo "$cleaned" } while [[ $# -gt 0 ]]; do case "$1" in --tag) TAG="${2:-}" if [[ -z "$TAG" ]]; then echo "error: --tag requires a value" >&2 exit 1 fi shift 2 ;; --name) APP_NAME="${2:-}" if [[ -z "$APP_NAME" ]]; then echo "error: --name requires a value" >&2 exit 1 fi NAME_SET=1 shift 2 ;; --bundle-id) BUNDLE_ID="${2:-}" if [[ -z "$BUNDLE_ID" ]]; then echo "error: --bundle-id requires a value" >&2 exit 1 fi BUNDLE_SET=1 shift 2 ;; --derived-data) DERIVED_DATA="${2:-}" if [[ -z "$DERIVED_DATA" ]]; then echo "error: --derived-data requires a value" >&2 exit 1 fi DERIVED_SET=1 shift 2 ;; -h|--help) usage exit 0 ;; *) echo "error: unknown option $1" >&2 usage exit 1 ;; esac done if [[ -n "$TAG" ]]; then TAG_ID="$(sanitize_bundle "$TAG")" TAG_SLUG="$(sanitize_path "$TAG")" if [[ "$NAME_SET" -eq 0 ]]; then APP_NAME="cmux STAGING ${TAG}" fi if [[ "$BUNDLE_SET" -eq 0 ]]; then BUNDLE_ID="com.cmuxterm.app.staging.${TAG_ID}" fi if [[ "$DERIVED_SET" -eq 0 ]]; then DERIVED_DATA="/tmp/cmux-staging-${TAG_SLUG}" fi fi XCODEBUILD_ARGS=( -project GhosttyTabs.xcodeproj -scheme cmux -configuration Release -destination 'platform=macOS' ) if [[ -n "$DERIVED_DATA" ]]; then XCODEBUILD_ARGS+=(-derivedDataPath "$DERIVED_DATA") fi if [[ -z "$TAG" ]]; then XCODEBUILD_ARGS+=( INFOPLIST_KEY_CFBundleName="$APP_NAME" INFOPLIST_KEY_CFBundleDisplayName="$APP_NAME" PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" ) fi XCODEBUILD_ARGS+=(build) xcodebuild "${XCODEBUILD_ARGS[@]}" sleep 0.2 FALLBACK_APP_NAME="$BASE_APP_NAME" SEARCH_APP_NAME="$APP_NAME" if [[ -n "$TAG" ]]; then SEARCH_APP_NAME="$BASE_APP_NAME" fi if [[ -n "$DERIVED_DATA" ]]; then APP_PATH="${DERIVED_DATA}/Build/Products/Release/${SEARCH_APP_NAME}.app" if [[ ! -d "${APP_PATH}" && "$SEARCH_APP_NAME" != "$FALLBACK_APP_NAME" ]]; then APP_PATH="${DERIVED_DATA}/Build/Products/Release/${FALLBACK_APP_NAME}.app" fi else APP_BINARY="$( find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/${SEARCH_APP_NAME}.app/Contents/MacOS/${SEARCH_APP_NAME}" -print0 \ | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ | sort -nr \ | head -n 1 \ | cut -d' ' -f2- )" if [[ -n "${APP_BINARY}" ]]; then APP_PATH="$(dirname "$(dirname "$(dirname "$APP_BINARY")")")" fi if [[ -z "${APP_PATH:-}" && "$SEARCH_APP_NAME" != "$FALLBACK_APP_NAME" ]]; then APP_BINARY="$( find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Release/${FALLBACK_APP_NAME}.app/Contents/MacOS/${FALLBACK_APP_NAME}" -print0 \ | xargs -0 /usr/bin/stat -f "%m %N" 2>/dev/null \ | sort -nr \ | head -n 1 \ | cut -d' ' -f2- )" if [[ -n "${APP_BINARY}" ]]; then APP_PATH="$(dirname "$(dirname "$(dirname "$APP_BINARY")")")" fi fi fi if [[ -z "${APP_PATH:-}" || ! -d "${APP_PATH}" ]]; then echo "${APP_NAME}.app not found in DerivedData" >&2 exit 1 fi # Staging always copies the built app and patches the plist to set an isolated # socket path, bundle id, and display name. This prevents conflicts with the # production cmux app. STAGING_APP_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app" rm -rf "$STAGING_APP_PATH" cp -R "$APP_PATH" "$STAGING_APP_PATH" INFO_PLIST="$STAGING_APP_PATH/Contents/Info.plist" if [[ -f "$INFO_PLIST" ]]; then /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :CFBundleName string $APP_NAME" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string $APP_NAME" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST" # Inject staging socket paths via LSEnvironment so the Release binary # (which defaults to /tmp/cmux.sock) uses isolated sockets instead. STAGING_SLUG="${TAG_SLUG:-staging}" APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux" CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-${STAGING_SLUG}.sock" CMUX_SOCKET="/tmp/cmux-${STAGING_SLUG}.sock" echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true /usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_PATH \"${CMUX_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" if [[ -S "$CMUXD_SOCKET" ]]; then for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do kill "$PID" 2>/dev/null || true done rm -f "$CMUXD_SOCKET" fi if [[ -S "$CMUX_SOCKET" ]]; then rm -f "$CMUX_SOCKET" fi /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$STAGING_APP_PATH" >/dev/null 2>&1 || true fi APP_PATH="$STAGING_APP_PATH" # Ensure any running instance is fully terminated, regardless of DerivedData path. /usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true sleep 0.3 # Kill any running staging instance; allow side-by-side with the main and dev apps. pkill -f "${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true sleep 0.3 CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd" if [[ -d "$PWD/cmuxd" ]]; then (cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast) fi if [[ -x "$CMUXD_SRC" ]]; then BIN_DIR="$APP_PATH/Contents/Resources/bin" mkdir -p "$BIN_DIR" cp "$CMUXD_SRC" "$BIN_DIR/cmuxd" chmod +x "$BIN_DIR/cmuxd" fi # Avoid inheriting cmux/ghostty environment variables from the terminal that # runs this script (often inside another cmux instance), which can cause # socket and resource-path conflicts. OPEN_CLEAN_ENV=( env -u CMUX_SOCKET_PATH -u CMUX_TAB_ID -u CMUX_PANEL_ID -u CMUXD_UNIX_PATH -u CMUX_TAG -u CMUX_BUNDLE_ID -u CMUX_SHELL_INTEGRATION -u GHOSTTY_BIN_DIR -u GHOSTTY_RESOURCES_DIR -u GHOSTTY_SHELL_FEATURES # Dev shells (including CI/Codex) often force-disable paging by exporting these. # Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less. -u GIT_PAGER -u GH_PAGER -u TERMINFO -u XDG_DATA_DIRS ) # Always inject staging socket paths via env to ensure they take effect # (LSEnvironment requires app restart to pick up plist changes). "${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH" osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true # Safety: ensure only one instance is running. sleep 0.2 PIDS=($(pgrep -f "${APP_PATH}/Contents/MacOS/" || true)) if [[ "${#PIDS[@]}" -gt 1 ]]; then NEWEST_PID="" NEWEST_AGE=999999 for PID in "${PIDS[@]}"; do AGE="$(ps -o etimes= -p "$PID" | tr -d ' ')" if [[ -n "$AGE" && "$AGE" -lt "$NEWEST_AGE" ]]; then NEWEST_AGE="$AGE" NEWEST_PID="$PID" fi done for PID in "${PIDS[@]}"; do if [[ "$PID" != "$NEWEST_PID" ]]; then kill "$PID" 2>/dev/null || true fi done fi