#!/bin/zsh # XX # XX XX # XX X # X XX # XX XX # XX X # X XX # XX X # XX XX XX # X XX XX XX # XX XX X X # XX X XX XX # X XX XX X # XX XX X XX # XX X XX X # X XX X XX # XX XX XX XX XX # XX XX XX X X X # X X X XX XX XX # XX XX XX X X X # XX XX XX XX XX XX # X XXXXXXX XX XX XX # XX X X X # XX XX XX XX # XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXX ## Always run from the home folder cd NORTHSLOPE_DIR="${HOME}/.northslope" mkdir -p "${NORTHSLOPE_DIR}" #============================================================================== # SELF-UPDATE #============================================================================== case "$1" in --skip-update|--child|skip) shift ;; *) curl_output=$(curl -fsSL https://setup.northslope.dev/setup.sh -o "${NORTHSLOPE_DIR}/setup.sh" 2>&1) curl_exit_code=$? if [[ ${curl_exit_code} -ne 0 ]]; then echo "'setup' Failure 🚫" echo "Failed to download updated setup.sh: ${curl_output}" exit 1 fi chmod +x "${NORTHSLOPE_DIR}/setup.sh" exec /bin/zsh "${NORTHSLOPE_DIR}/setup.sh" --skip-update "$@" ;; esac # When invoked via pipe (e.g. curl ... | zsh), stdin is the pipe — not a TTY. # Reconnect stdin to the terminal so interactive prompts work. if [[ ! -t 0 ]] && [[ -r /dev/tty ]]; then exec < /dev/tty fi #============================================================================== # CONSTANTS #============================================================================== NORTHSLOPE_PACKAGES_DIR="${NORTHSLOPE_DIR}/packages" NS_MACHINE_SETUP_DIR="${NORTHSLOPE_PACKAGES_DIR}/ns-machine-setup" NS_MACHINE_SETUP_REPO="https://github.com/northslopetech/ns-machine-setup.git" NS_MACHINE_SETUP_VENV="${NORTHSLOPE_PACKAGES_DIR}/ns-machine-setup-venv" SCRIPT_VERSION="v5.23.1" mkdir -p "${NORTHSLOPE_PACKAGES_DIR}" #============================================================================== # LOGGING #============================================================================== _NS_RESET='\033[0m' _NS_BOLD='\033[1m' _NS_GREEN='\033[32m' _NS_YELLOW='\033[33m' _NS_RED='\033[31m' _NS_DIM='\033[2m' if [[ ! -t 1 ]] || [[ -n "${NO_COLOR:-}" ]]; then _NS_RESET='' _NS_BOLD='' _NS_GREEN='' _NS_YELLOW='' _NS_RED='' _NS_DIM='' fi log_section() { printf "\n${_NS_BOLD}▸ %s${_NS_RESET}\n" "$1"; } log_ok() { printf " ${_NS_GREEN}✓${_NS_RESET} %s\n" "$1"; } log_info() { printf " → %s\n" "$1"; } log_warn() { printf "\n ${_NS_YELLOW}⚠${_NS_RESET} %s\n" "$1"; } log_error() { printf " ${_NS_RED}✗${_NS_RESET} %s\n" "$1"; } printf "${_NS_BOLD}Northslope Machine Setup ${SCRIPT_VERSION}${_NS_RESET} 🚀\n" echo "${SCRIPT_VERSION}" > "${NORTHSLOPE_DIR}/setup-version" echo # Extract --debug before prereqs so NS_SETUP_DEBUG is available to all prereq scripts. if [[ "$1" == "--debug" ]]; then export NS_SETUP_DEBUG=1 shift fi #============================================================================== # FILE PERMISSIONS #============================================================================== log_section "File Permissions" mkdir -p "${HOME}/.northslope" _fp_errors=() if [[ ! -w "${HOME}/.northslope" ]]; then _fp_errors+=("No write permission for directory: ~/.northslope") fi for _fp_file in "${HOME}/.zshrc" "${HOME}/.bashrc" "${HOME}/.gitconfig"; do if [[ -e "${_fp_file}" ]]; then if [[ ! -w "${_fp_file}" ]]; then _fp_errors+=("No write permission for file: ${_fp_file}") fi else touch "${_fp_file}" 2>/dev/null || _fp_errors+=("Cannot create file: ${_fp_file}") fi done if [[ ${#_fp_errors[@]} -gt 0 ]]; then log_error "Permission errors detected:" for _fp_err in "${_fp_errors[@]}"; do printf " - %s\n" "${_fp_err}" done exit 1 fi log_ok "File permissions OK" #============================================================================== # XCODE COMMAND LINE TOOLS #============================================================================== log_section "Xcode Command Line Tools" xcode_output=$(xcode-select -p 2>&1) xcode_exit_code=$? if [[ ${xcode_exit_code} -ne 0 ]]; then log_info "Installing Xcode Command Line Tools..." xcode-select --install xcode_install_trigger_code=$? if [[ ${xcode_install_trigger_code} -ne 0 ]]; then log_error "xcode-select --install failed. Please install manually and re-run setup." exit 1 fi log_warn "A dialog has appeared — click 'Install' and wait for it to finish." log_info "Waiting for installation to complete..." until xcode-select -p &>/dev/null; do read -s -k 1 "?Once installation is complete, press Enter to continue..." /dev/null || true echo "" done log_ok "Xcode Command Line Tools installed" else log_ok "Xcode Command Line Tools already installed" fi #============================================================================== # HOMEBREW OWNERSHIP FIX #============================================================================== if [[ -d /opt/homebrew ]]; then homebrew_owner=$(stat -f '%Su' /opt/homebrew) if [[ "${homebrew_owner}" != "$(whoami)" ]]; then log_section "Homebrew Ownership" log_info "Fixing /opt/homebrew ownership (changed by macOS update)..." log_info "Please enter your computer password when prompted." sudo chown -R "$(whoami)" /opt/homebrew if [[ $? -ne 0 ]]; then log_error "Failed to fix /opt/homebrew ownership. Please run: sudo chown -R \$(whoami) /opt/homebrew" exit 1 fi log_ok "Homebrew ownership fixed" fi fi #============================================================================== # HOMEBREW #============================================================================== log_section "Homebrew" if ! brew --help > /dev/null 2>&1; then log_info "Installing Homebrew (this can take 10–20 minutes)..." log_info "You will be prompted for your computer password." sudo -v BREW_INSTALL_SHA256="dfd5145fe2aa5956a600e35848765273f5798ce6def01bd08ecec088a1268d91" # Pinned to Homebrew/install@61f57de — update SHA + checksum together when bumping BREW_INSTALL_TMP="$(mktemp)" curl -fsSL "https://raw.githubusercontent.com/Homebrew/install/61f57debbf8b06e07daf60e514bed21f81df493e/install.sh" -o "$BREW_INSTALL_TMP" shasum -a 256 "$BREW_INSTALL_TMP" | grep -q "^${BREW_INSTALL_SHA256}" || { echo "Error: Homebrew installer checksum mismatch — aborting" >&2; rm -f "$BREW_INSTALL_TMP"; exit 1 } bash "$BREW_INSTALL_TMP" rm -f "$BREW_INSTALL_TMP" eval "$(/opt/homebrew/bin/brew shellenv)" fi if ! brew --help > /dev/null 2>&1; then log_error "Homebrew installation failed. Please contact @tnguyen." exit 1 fi log_ok "Homebrew ready" #============================================================================== # SET ZSH TO DEFAULT SHELL #============================================================================== log_section "Default Shell" _current_shell=$(dscl . -read "/Users/$(whoami)" UserShell 2>/dev/null | awk '{print $NF}') if [[ "${_current_shell}" == "/bin/zsh" ]]; then log_ok "Default shell already /bin/zsh" else chsh -s /bin/zsh if [[ $? -ne 0 ]]; then log_warn "chsh failed — set manually with: chsh -s /bin/zsh" else log_ok "Default shell set to /bin/zsh" fi fi #============================================================================== # HOMEBREW SHELL RC #============================================================================== eval "$(/opt/homebrew/bin/brew shellenv)" #============================================================================== # GITHUB CLI #============================================================================== log_section "GitHub CLI" # Use the brew-installed binary directly to avoid asdf shim interference. GH_BIN="$(brew --prefix gh 2>/dev/null)/bin/gh" if [[ ! -x "${GH_BIN}" ]]; then log_info "Installing GitHub CLI..." HOMEBREW_NO_AUTO_UPDATE=1 brew install gh GH_BIN="$(brew --prefix gh 2>/dev/null)/bin/gh" if [[ ! -x "${GH_BIN}" ]]; then log_error "GitHub CLI not found after install." exit 1 fi elif [[ -n "$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated gh 2>/dev/null)" ]]; then log_info "Upgrading GitHub CLI..." if ! brew upgrade gh; then log_error "Failed to upgrade GitHub CLI." exit 1 fi fi log_ok "GitHub CLI ready" if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] GH_BIN=%s\n" "${GH_BIN}" printf " [debug] gh version: %s\n" "$("${GH_BIN}" --version 2>&1 | head -1)" printf " [debug] which gh: %s\n" "$(which gh 2>/dev/null)" printf " [debug] type -a gh:\n"; type -a gh 2>/dev/null | sed 's/^/ /' fi #============================================================================== # GIT USER.NAME #============================================================================== CURRENT_GIT_NAME=$(git config --global user.name 2>/dev/null) if [[ -z "${CURRENT_GIT_NAME}" ]]; then log_section "git user.name" while true; do printf " → What is your full name? (e.g. Tam Nguyen): " read -r GIT_USER_NAME if [[ -n "${GIT_USER_NAME}" ]]; then git config --global user.name "${GIT_USER_NAME}" break fi log_warn "Name cannot be empty." done log_ok "git user.name set" fi #============================================================================== # GIT USER.EMAIL #============================================================================== CURRENT_GIT_EMAIL=$(git config --global user.email 2>/dev/null) if [[ -z "${CURRENT_GIT_EMAIL}" ]]; then log_section "git user.email" while true; do printf " → What is your email address? (e.g. test@northslopetech.com): " read -r GIT_USER_EMAIL if [[ -n "${GIT_USER_EMAIL}" ]]; then git config --global user.email "${GIT_USER_EMAIL}" break fi log_warn "Email cannot be empty." done log_ok "git user.email set" fi #============================================================================== # GH AUTH #============================================================================== log_section "gh auth" # Force https as the default protocol for all gh operations (repo clone, # pr create, etc.) regardless of what a previous manual `gh auth login` # chose on this machine. CURRENT_PROTOCOL="$("${GH_BIN}" config get git_protocol -h github.com 2>/dev/null)" if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] GH_BIN=%s\n" "${GH_BIN}" printf " [debug] git_protocol (current)=%s\n" "${CURRENT_PROTOCOL}" fi if [[ "${CURRENT_PROTOCOL}" != "https" ]]; then "${GH_BIN}" config set git_protocol https -h github.com fi _nslp_login() { "${GH_BIN}" auth status --json hosts --jq '.hosts | to_entries[] | .value[] | select(.login | endswith("_nslp")) | .login' 2>/dev/null | head -1 } _active_login() { "${GH_BIN}" auth status --json hosts --jq '.hosts | to_entries[] | .value[] | select(.active == true) | .login' 2>/dev/null | head -1 } "${GH_BIN}" auth status > /dev/null 2>&1 GH_AUTH_ALREADY_SET=$? if [[ ${GH_AUTH_ALREADY_SET} -ne 0 ]]; then log_info "Not authorized. Authenticating..." GH_AUTH_ATTEMPTS=0 while true; do GH_AUTH_ATTEMPTS=$((GH_AUTH_ATTEMPTS + 1)) if [[ ${GH_AUTH_ATTEMPTS} -gt 5 ]]; then log_error "Too many failed login attempts. Press Ctrl+C to exit." break fi "${GH_BIN}" auth login --hostname github.com --git-protocol https --web gh_auth_status=$? if [[ ${gh_auth_status} -eq 0 ]]; then NSLP_USER=$(_nslp_login) if [[ -z "${NSLP_USER}" ]]; then log_warn "You must log in with your Northslope GitHub account (username ending in _nslp)." log_info "If you don't have one, visit: https://setup.northslope.dev/github-enterprise" WRONG_USER=$(_active_login) [[ -n "${WRONG_USER}" ]] && "${GH_BIN}" auth logout -u "${WRONG_USER}" -h github.com > /dev/null 2>&1 continue fi log_ok "gh auth authorized" break else log_error "gh auth login failed or was interrupted." break fi done else # gh auth status succeeding only means SOME account is authenticated — it # could be a personal/old account left active from before this machine was # set up for Northslope. Verify a Northslope (_nslp) account is configured # before declaring victory, same as the fresh-login path above. NSLP_USER=$(_nslp_login) if [[ -z "${NSLP_USER}" ]]; then log_warn "Authorized, but no Northslope GitHub account (username ending in _nslp) is configured." log_info "Sign in with your _nslp account to continue." "${GH_BIN}" auth login --hostname github.com --git-protocol https --web NSLP_USER=$(_nslp_login) if [[ -z "${NSLP_USER}" ]]; then log_error "Still no _nslp account found. Contact @tnguyen." exit 1 fi fi log_ok "gh auth already authorized" fi # gh org list, gh api, and gh auth setup-git all act on whichever account is # currently ACTIVE. If a non-_nslp account (e.g. a personal account, or a # second _nslp-suffixed account that isn't active) was authenticated first, # every downstream check/clone would silently run as the wrong user — causing # spurious "not a member of northslopetech" failures and clone permission # errors. Force the _nslp account active before anything else uses it. if [[ -n "${NSLP_USER}" ]]; then ACTIVE_USER=$(_active_login) if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] NSLP_USER=%s ACTIVE_USER=%s\n" "${NSLP_USER}" "${ACTIVE_USER}" fi if [[ "${ACTIVE_USER}" != "${NSLP_USER}" ]]; then SWITCH_OUTPUT=$("${GH_BIN}" auth switch -u "${NSLP_USER}" -h github.com 2>&1) SWITCH_EXIT=$? if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] gh auth switch exit=%d output=%s\n" "${SWITCH_EXIT}" "${SWITCH_OUTPUT}" fi if [[ ${SWITCH_EXIT} -ne 0 ]]; then log_warn "Failed to switch to ${NSLP_USER}: ${SWITCH_OUTPUT}" fi fi fi if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] gh auth status --json hosts before setup-git:\n" "${GH_BIN}" auth status --json hosts 2>/dev/null | sed 's/^/ /' fi "${GH_BIN}" auth setup-git #============================================================================== # GITHUB SSO #============================================================================== log_section "GitHub SSO" # Parse the _nslp account from plain JSON output using /usr/bin/python3 (stdlib # JSON, always available from Xcode CLT). We avoid gh's inline --jq (gojq) # because it can silently miss non-active accounts, causing spurious re-auth on # machines where the _nslp account exists but is not the currently active account. _gh_auth_json() { "${GH_BIN}" auth status --json hosts 2>/dev/null } _nslp_user() { _gh_auth_json | /usr/bin/python3 -c " import sys, json data = json.load(sys.stdin) for accounts in data.get('hosts', {}).values(): for a in accounts: if str(a.get('login', '')).endswith('_nslp'): print(a['login']) sys.exit(0) " 2>/dev/null | head -1 } # Check for _nslp user FIRST — scope refresh is meaningless without one and # triggers a spurious device-flow on fresh machines. if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] GH_BIN=%s\n" "${GH_BIN}" printf " [debug] gh auth status --json hosts (stdout):\n" "${GH_BIN}" auth status --json hosts 2>/dev/null | head -5 | sed 's/^/ /' printf " [debug] gh auth status --json hosts (stderr):\n" "${GH_BIN}" auth status --json hosts 2>&1 >/dev/null | head -5 | sed 's/^/ /' printf " [debug] python3 parse result:\n" "${GH_BIN}" auth status --json hosts 2>/dev/null \ | /usr/bin/python3 -c " import sys, json data = json.load(sys.stdin) for accounts in data.get('hosts', {}).values(): for a in accounts: if str(a.get('login', '')).endswith('_nslp'): print(a['login']) " 2>&1 | head -5 | sed 's/^/ /' printf " [debug] _nslp_user()=%s\n" "$(_nslp_user)" fi NSLP_USER=$(_nslp_user) if [[ -z "${NSLP_USER}" ]]; then log_warn "No _nslp account found — opening GitHub login..." "${GH_BIN}" auth login --hostname github.com --git-protocol https --web if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] gh auth status after login (stdout):\n" "${GH_BIN}" auth status --json hosts 2>/dev/null | head -3 | sed 's/^/ /' printf " [debug] gh auth status after login (stderr):\n" "${GH_BIN}" auth status --json hosts 2>&1 >/dev/null | head -3 | sed 's/^/ /' printf " [debug] _nslp_user() after login=%s\n" "$(_nslp_user)" fi NSLP_USER=$(_nslp_user) if [[ -z "${NSLP_USER}" ]]; then log_warn "GitHub authentication failed — sign in with your _nslp account and re-run setup." exit 1 fi fi SSO_ATTEMPT=0 while true; do SSO_ATTEMPT=$((SSO_ATTEMPT + 1)) if [[ ${SSO_ATTEMPT} -gt 5 ]]; then log_error "Too many SSO retries — re-run setup." exit 1 fi GH_SSO_OUTPUT=$("${GH_BIN}" api repos/northslopetech/ns-cli 2>&1) GH_SSO_EXIT=$? if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] gh api exit=%d output=%s\n" "${GH_SSO_EXIT}" "${GH_SSO_OUTPUT:0:120}" fi if echo "${GH_SSO_OUTPUT}" | grep -qi "saml"; then log_warn "GitHub SSO Authorization Required" log_info "Your GitHub CLI token must be authorized for the northslopetech SSO org." log_info "Visit: https://github.com/settings/connections/applications" log_info "Find 'GitHub CLI', click 'Configure SSO' → authorize 'northslopetech'." echo "" printf " → Press Enter when complete... "; read -r _ elif [[ ${GH_SSO_EXIT} -eq 0 ]]; then log_ok "GitHub SSO authorized" break else log_warn "Could not verify SSO (${GH_SSO_OUTPUT:0:120}). Continuing..." break fi done #============================================================================== # NORTHSLOPETECH ORG #============================================================================== log_section "northslopetech org" _org_list() { "${GH_BIN}" org list 2>&1 } if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] GH_BIN=%s\n" "${GH_BIN}" printf " [debug] gh org list:\n" _org_list | sed 's/^/ /' fi ORG_LIST=$(_org_list) if echo "${ORG_LIST}" | grep -q "^northslope-tech$"; then log_warn "Your account is in 'northslope-tech' (the old org)." log_info "Follow https://setup.northslope.dev/github-enterprise to migrate." exit 1 fi if ! echo "${ORG_LIST}" | grep -q "^northslopetech$"; then log_warn "You are not a member of the northslopetech org." log_info "Contact @tnguyen to be added. Press Enter when you have an invitation..." read open 'https://github.com/orgs/northslopetech/invitation' log_info "Press Enter after accepting the invitation..." read ORG_LIST=$(_org_list) if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] gh org list (retry):\n" echo "${ORG_LIST}" | sed 's/^/ /' fi if ! echo "${ORG_LIST}" | grep -q "northslopetech"; then log_error "Still not in northslopetech org. Contact @tnguyen." exit 1 fi fi log_ok "northslopetech org verified" #============================================================================== # NS-MACHINE-SETUP #============================================================================== log_section "ns-machine-setup" if [[ ! -d "${NS_MACHINE_SETUP_DIR}/.git" ]]; then log_info "Cloning ns-machine-setup..." git clone --quiet "${NS_MACHINE_SETUP_REPO}" "${NS_MACHINE_SETUP_DIR}" if [[ $? -ne 0 ]]; then log_error "Failed to clone ns-machine-setup. Cannot continue." exit 1 fi fi # Self-heal ownership: a clone left owned by another user (e.g. an earlier # run of this script under sudo, or a home-directory migration that didn't # preserve UIDs) makes git fail with "Permission denied" writing new refs — # surfacing as a misleading fetch failure below. Same pattern as # homebrew_ownership.sh. Scan recursively (-print -quit stops at the first # hit) rather than just stat-ing the top-level dir, since a prior sudo # invocation of just `git fetch` can leave the root correctly owned while # only files under .git are foreign-owned. _ns_setup_bad_owner=$(find "${NS_MACHINE_SETUP_DIR}" ! -user "$(whoami)" -print -quit 2>/dev/null) if [[ -n "${_ns_setup_bad_owner}" ]]; then log_info "Fixing ns-machine-setup ownership (found files not owned by $(whoami))..." log_info "Please enter your computer password when prompted." sudo chown -R "$(whoami)" "${NS_MACHINE_SETUP_DIR}" if [[ $? -ne 0 ]]; then log_error "Failed to fix ns-machine-setup ownership. Please run: sudo chown -R \$(whoami) ${NS_MACHINE_SETUP_DIR}" exit 1 fi fi # Force HTTPS: northslopetech enforces SAML SSO, and SSH requires each # individual key to be separately authorized for SSO. An existing clone left # on an SSH origin (from before this fix, or a stale machine) would otherwise # fail the fetch below before ever reaching the Python-side migration. _origin_url=$(git -C "${NS_MACHINE_SETUP_DIR}" config --get remote.origin.url 2>/dev/null) if [[ -n "${_origin_url}" && "${_origin_url}" != "${NS_MACHINE_SETUP_REPO}" ]]; then git -C "${NS_MACHINE_SETUP_DIR}" remote set-url origin "${NS_MACHINE_SETUP_REPO}" fi NS_MACHINE_SETUP_TARGET_VERSION=$(curl -fsSL "https://setup.northslope.dev/version.txt" 2>/dev/null | tr -d '[:space:]') if [[ -z "${NS_MACHINE_SETUP_TARGET_VERSION}" ]]; then log_error "Failed to read target version from setup.northslope.dev/version.txt. Cannot continue." exit 1 fi _fetch_output=$(git -C "${NS_MACHINE_SETUP_DIR}" fetch --tags origin 2>&1) _fetch_exit=$? if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] git fetch exit=%d\n" "${_fetch_exit}" if [[ -n "${_fetch_output}" ]]; then printf " [debug] git fetch output:\n" printf "%s\n" "${_fetch_output}" | sed 's/^/ /' fi fi if [[ ${_fetch_exit} -ne 0 ]]; then log_error "Failed to fetch ns-machine-setup tags (exit ${_fetch_exit}). Check SSH/SSO access to github.com/northslopetech, or file permissions in ${NS_MACHINE_SETUP_DIR}. Cannot continue." if [[ -n "${_fetch_output}" ]]; then printf "%s\n" "${_fetch_output}" | sed 's/^/ /' fi exit 1 fi git -C "${NS_MACHINE_SETUP_DIR}" restore . >/dev/null 2>&1 git -C "${NS_MACHINE_SETUP_DIR}" clean -fd >/dev/null 2>&1 git -C "${NS_MACHINE_SETUP_DIR}" checkout --quiet -f "${NS_MACHINE_SETUP_TARGET_VERSION}" if [[ $? -ne 0 ]]; then log_error "Failed to checkout ns-machine-setup ${NS_MACHINE_SETUP_TARGET_VERSION}. Cannot continue." exit 1 fi log_ok "ns-machine-setup at ${NS_MACHINE_SETUP_TARGET_VERSION}" #============================================================================== # PYTHON (UV) #============================================================================== log_section "uv" UV_BIN="$(brew --prefix uv 2>/dev/null)/bin/uv" if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] UV_BIN=%s\n" "${UV_BIN}" printf " [debug] which uv: %s\n" "$(which uv 2>/dev/null)" fi if [[ ! -x "$UV_BIN" ]]; then log_info "uv not found — installing via brew..." HOMEBREW_NO_AUTO_UPDATE=1 brew install uv if [[ $? -ne 0 ]]; then log_error "uv installation failed." exit 1 fi UV_BIN="$(brew --prefix uv)/bin/uv" if [[ ! -x "$UV_BIN" ]]; then log_error "uv not found after brew install." exit 1 fi elif [[ -n "$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated uv 2>/dev/null)" ]]; then log_info "Upgrading uv..." if ! brew upgrade uv; then log_error "Failed to upgrade uv." exit 1 fi fi log_ok "uv ready ($("${UV_BIN}" --version 2>/dev/null))" # Self-heal cache ownership: a root-owned ~/.cache (left by an earlier sudo # run) makes uv fail with "Failed to initialize cache". Same pattern as # homebrew_ownership.sh. # Resolve the home directory from the account database, not $HOME or # XDG_CACHE_HOME: both are caller-controlled and this block runs sudo chown. # If the lookup fails we skip the self-heal rather than chown an untrusted path. NS_ACCOUNT_HOME="$(dscl . -read "/Users/$(whoami)" NFSHomeDirectory 2>/dev/null | sed -n 's/^NFSHomeDirectory: //p')" # A symlinked ~/.cache points outside the boundary we're willing to chown, so # skip the self-heal there too. (chown -R below is safe: BSD chown defaults to # -P and does not follow symlinks found during the walk.) if [[ -n "${NS_ACCOUNT_HOME}" && ! -L "${NS_ACCOUNT_HOME}/.cache" ]]; then NS_CACHE_DIR="${NS_ACCOUNT_HOME}/.cache" mkdir -p "${NS_CACHE_DIR}" 2>/dev/null # A writable ~/.cache/uv can still hold root-owned buckets underneath, so # scan recursively. Same pattern as ns_machine_setup.sh: -print -quit stops # at the first hit, so this stays cheap on a large cache. _uv_cache_bad_owner=$(find "${NS_CACHE_DIR}/uv" ! -user "$(whoami)" -print -quit 2>/dev/null) if [[ ! -w "${NS_CACHE_DIR}" || -n "${_uv_cache_bad_owner}" ]]; then log_info "Fixing ${NS_CACHE_DIR} ownership..." log_info "Please enter your computer password when prompted." # Top level non-recursive: ~/.cache can hold gigabytes of other tools' # caches. Recursive only under uv's own cache, which is ours to own. # -h: never follow, in case the path is swapped for a symlink between # the check above and this chown. No-op on a real directory. if ! sudo chown -h "$(whoami)" "${NS_CACHE_DIR}"; then log_error "Failed to fix cache ownership. Please run: sudo chown \$(whoami) ${NS_CACHE_DIR}" exit 1 fi if [[ -e "${NS_CACHE_DIR}/uv" ]] && ! sudo chown -R "$(whoami)" "${NS_CACHE_DIR}/uv"; then log_error "Failed to fix cache ownership. Please run: sudo chown -R \$(whoami) ${NS_CACHE_DIR}/uv" exit 1 fi fi fi log_section "Virtualenv" # Python version installed and managed by uv (not brew). UV_PYTHON_VERSION="3.13.13" VENV_PYTHON3="${NS_MACHINE_SETUP_VENV}/bin/python3" VENV_NEEDS_RECREATE=1 if [[ -x "$VENV_PYTHON3" ]] && "$VENV_PYTHON3" -c "import sys; exit(0 if sys.version.startswith('${UV_PYTHON_VERSION} ') else 1)" 2>/dev/null && "$VENV_PYTHON3" -m pip --version &>/dev/null; then VENV_NEEDS_RECREATE=0 fi if [[ -n "${NS_SETUP_DEBUG:-}" ]]; then printf " [debug] NS_MACHINE_SETUP_VENV=%s\n" "${NS_MACHINE_SETUP_VENV}" printf " [debug] VENV_PYTHON3=%s\n" "${VENV_PYTHON3}" printf " [debug] venv python exists: %s\n" "$([[ -x "$VENV_PYTHON3" ]] && echo yes || echo no)" printf " [debug] VENV_NEEDS_RECREATE=%s\n" "${VENV_NEEDS_RECREATE}" fi if [[ $VENV_NEEDS_RECREATE -eq 1 ]]; then if [[ -e "${NS_MACHINE_SETUP_VENV}" ]]; then log_info "Removing invalid virtualenv..." rm -rf "${NS_MACHINE_SETUP_VENV}" fi log_info "Installing Python ${UV_PYTHON_VERSION} via uv..." "${UV_BIN}" python install "${UV_PYTHON_VERSION}" --quiet if [[ $? -ne 0 ]]; then log_error "Failed to install Python ${UV_PYTHON_VERSION} via uv." exit 1 fi log_info "Creating virtualenv..." "${UV_BIN}" venv --seed --python "${UV_PYTHON_VERSION}" "${NS_MACHINE_SETUP_VENV}" if [[ $? -ne 0 ]]; then log_error "Failed to create virtualenv at ${NS_MACHINE_SETUP_VENV}." exit 1 fi fi "${NS_MACHINE_SETUP_VENV}/bin/python3" -m pip install --quiet questionary if [[ $? -ne 0 ]]; then log_error "Failed to install questionary." exit 1 fi log_ok "Virtualenv ready" #============================================================================== # HAND OFF TO PYTHON INSTALLER # # Usage: # setup — re-run update for your saved profile # setup config — reconfigure your profile # setup tasks — pick and run individual tasks interactively # setup --task — run one or more specific task IDs # setup --toggle — toggle a task as user-managed in install-config.json # setup --debug [...] — enable verbose debug output (pass through to installer) #============================================================================== case "$1" in --toggle) _toggle_id="${2:-}" if [[ -z "$_toggle_id" ]]; then log_error "--toggle requires a task ID" printf " Usage: setup --toggle \n" exit 1 fi _config_path="${NORTHSLOPE_DIR}/install-config.json" if ! command -v jq &>/dev/null; then log_error "jq is required for --toggle (run 'setup' first to install it)" exit 1 fi # Bootstrap a minimal config if none exists yet if [[ ! -f "$_config_path" ]]; then printf '{\n "version": 2,\n "tasks": {}\n}\n' > "$_config_path" fi _current=$(jq -r --arg id "$_toggle_id" '.tasks[$id] // empty' "$_config_path") if [[ "$_current" == "user-managed" ]]; then jq --arg id "$_toggle_id" 'del(.tasks[$id])' "$_config_path" \ > "${_config_path}.tmp" && mv "${_config_path}.tmp" "$_config_path" log_ok "${_toggle_id} — now managed by ns-machine-setup" else jq --arg id "$_toggle_id" '.tasks[$id] = "user-managed"' "$_config_path" \ > "${_config_path}.tmp" && mv "${_config_path}.tmp" "$_config_path" log_ok "${_toggle_id} — marked user-managed (ns-machine-setup will skip it)" fi exit 0 ;; config) exec "${NS_MACHINE_SETUP_VENV}/bin/python3" "${NS_MACHINE_SETUP_DIR}/install.py" --config ;; tasks) exec "${NS_MACHINE_SETUP_VENV}/bin/python3" "${NS_MACHINE_SETUP_DIR}/install.py" --tasks ;; --task) shift exec "${NS_MACHINE_SETUP_VENV}/bin/python3" "${NS_MACHINE_SETUP_DIR}/install.py" --task "$@" ;; *) exec "${NS_MACHINE_SETUP_VENV}/bin/python3" "${NS_MACHINE_SETUP_DIR}/install.py" --update ;; esac