#!/usr/bin/env bash # cspell:ignore loglevel macappstore NONINTERACTIVE reshim testflight TESTFLIGHT set -Eeuo pipefail IFS=$'\n\t' if [[ -z "${BASH_SOURCE[0]:-}" ]]; then printf '%s\n' \ 'Popina setup cannot run directly from a pipe because interactive installers may consume its input.' \ 'Download the complete script first:' \ ' curl -fsSL https://popina.sh/install -o /tmp/popina-install && bash /tmp/popina-install && exec zsh -l' >&2 exit 1 fi readonly INSTALLER_VERSION="0.2.2" readonly NODE_VERSION="24.14.1" readonly PNPM_VERSION="11.17.0" readonly TESTFLIGHT_APP_ID="899247664" readonly -a FORMULAE=(mise git gh) PROFILE="web" WORKSPACE_DIR="${POPINA_WORKSPACE:-$HOME/Workspace}" POPINA_CLI_VERSION="${POPINA_CLI_VERSION:-latest}" ASSUME_YES=false DRY_RUN=false SHOULD_INSTALL_TEAM_APPS="ask" SHOULD_INITIALIZE_PROJECT="ask" HAS_MANUAL_APP_STEP=false CURRENT_PHASE="Preflight" if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then readonly BOLD=$'\033[1m' readonly MUTED=$'\033[2m' readonly CYAN=$'\033[36m' readonly GREEN=$'\033[32m' readonly YELLOW=$'\033[33m' readonly RED=$'\033[31m' readonly RESET=$'\033[0m' else readonly BOLD="" readonly MUTED="" readonly CYAN="" readonly GREEN="" readonly YELLOW="" readonly RED="" readonly RESET="" fi print_line() { printf '%b\n' "$*" } step() { print_line "\n${CYAN}${BOLD}→${RESET} ${BOLD}$*${RESET}" } phase() { local number=$1 local title=$2 CURRENT_PHASE="Phase ${number} of 3 — ${title}" print_line "\n${CYAN}${BOLD}${CURRENT_PHASE}${RESET}" } success() { print_line "${GREEN}✓${RESET} $*" } note() { print_line "${MUTED}$*${RESET}" } warn() { print_line "${YELLOW}!${RESET} $*" >&2 } fail() { print_line "${RED}Error:${RESET} $*" >&2 exit 1 } handle_error() { local status=$? local line=${1:-unknown} trap - ERR print_line "\n${RED}Setup stopped during ${CURRENT_PHASE} (near line ${line}).${RESET}" >&2 print_line "Nothing already installed has been removed. Fix the issue, then run the same command again to resume." >&2 exit "$status" } trap 'handle_error "$LINENO"' ERR print_help() { cat <<'HELP' Popina macOS developer setup Usage: curl -fsSL https://popina.sh/install -o /tmp/popina-install bash /tmp/popina-install [options] && exec zsh -l Options: --profile web Select the supported web developer profile --workspace PATH Choose the workspace directory (default: ~/Workspace) --skip-apps Skip Slack, Linear, Figma, Bruno, Chrome, VS Code, Raycast, and TestFlight --skip-project Install the machine toolchain without running popina init --dry-run Show the plan without changing the Mac --yes Accept installer defaults --help Show this help Environment: POPINA_WORKSPACE Default projects directory POPINA_CLI_VERSION Override the Popina CLI npm version (default: latest) NO_COLOR Disable colored output HELP } while (($# > 0)); do case "$1" in --profile) (($# >= 2)) || fail '--profile requires a value.' PROFILE="$2" shift 2 ;; --profile=*) PROFILE="${1#*=}" shift ;; --workspace) (($# >= 2)) || fail '--workspace requires a path.' WORKSPACE_DIR="$2" shift 2 ;; --workspace=*) WORKSPACE_DIR="${1#*=}" shift ;; --skip-apps) SHOULD_INSTALL_TEAM_APPS=false shift ;; --skip-project) SHOULD_INITIALIZE_PROJECT=false shift ;; --dry-run) DRY_RUN=true shift ;; --yes) ASSUME_YES=true shift ;; --help|-h) print_help exit 0 ;; *) fail "Unknown option: $1. Run with --help for usage." ;; esac done case "$PROFILE" in web) ;; mobile) fail 'The mobile developer profile is coming later. Use --profile web for now.' ;; *) fail "Unknown profile: ${PROFILE}. The supported profile is web." ;; esac expand_workspace_path() { [[ "$WORKSPACE_DIR" =~ [^[:space:]] ]] || fail 'The workspace path cannot be empty.' if [[ "$WORKSPACE_DIR" == \~ ]]; then WORKSPACE_DIR="$HOME" elif [[ "$WORKSPACE_DIR" == \~/* ]]; then WORKSPACE_DIR="$HOME/${WORKSPACE_DIR:2}" elif [[ "$WORKSPACE_DIR" != /* ]]; then WORKSPACE_DIR="$HOME/$WORKSPACE_DIR" fi } confirm() { local prompt=$1 local default_answer=${2:-yes} local hint='[Y/n]' local answer='' if [[ "$ASSUME_YES" == true ]]; then return 0 fi has_tty || fail 'Interactive input is unavailable. Re-run in a terminal or pass --yes with --skip-project.' if [[ "$default_answer" == no ]]; then hint='[y/N]' fi while true; do printf '%b' "${BOLD}?${RESET} ${prompt} ${MUTED}${hint}${RESET} " > /dev/tty IFS= read -r answer < /dev/tty || fail 'Could not read your answer.' if [[ -z "$answer" ]]; then [[ "$default_answer" == yes ]] return fi case "$answer" in y|Y|yes|YES|Yes) return 0 ;; n|N|no|NO|No) return 1 ;; *) print_line 'Please answer yes or no.' > /dev/tty ;; esac done } has_tty() { (: < /dev/tty) 2>/dev/null } prompt_workspace() { local answer='' if [[ "$ASSUME_YES" == true ]]; then expand_workspace_path return fi printf '%b' "${BOLD}?${RESET} Workspace directory ${MUTED}[${WORKSPACE_DIR}]${RESET} " > /dev/tty IFS= read -r answer < /dev/tty || fail 'Could not read the workspace path.' if [[ -n "$answer" ]]; then WORKSPACE_DIR="$answer" fi expand_workspace_path } ensure_macos() { [[ "$(uname -s)" == Darwin ]] || fail 'This installer currently supports macOS only. Linux support is coming later.' local architecture architecture="$(uname -m)" case "$architecture" in arm64|x86_64) success "macOS detected (${architecture})" ;; *) fail "Unsupported Mac architecture: ${architecture}." ;; esac } print_plan() { expand_workspace_path print_line "\n${BOLD}What this installer will do${RESET}" print_line " 1. Install Homebrew, mise, Git, GitHub CLI, OrbStack, and Google Cloud CLI." if [[ "$SHOULD_INSTALL_TEAM_APPS" == false ]]; then print_line " 2. Skip optional team applications." elif [[ "$ASSUME_YES" == true ]]; then print_line " 2. Install the eight Popina team applications." else print_line " 2. Offer the eight Popina team applications." fi print_line " 3. Install the official @popina/cli with pinned Node.js and pnpm versions." if [[ "$SHOULD_INITIALIZE_PROJECT" == false ]]; then print_line " 4. Leave workspace and project setup for later." else print_line " 4. Create ${WORKSPACE_DIR} and use popina init to set up pragma-web." fi print_line "\n${MUTED}Team apps: Slack, Linear, Figma, Bruno, Chrome, VS Code, Raycast, and TestFlight.${RESET}" print_line "${MUTED}You may be asked for your macOS password. GitHub and Google authentication open in your browser.${RESET}" print_line "${MUTED}TestFlight comes from the Mac App Store and may require Apple Account authentication.${RESET}" print_line "${MUTED}The installer is safe to rerun: existing tools and applications are kept.${RESET}" } print_dry_run_status() { load_homebrew_environment print_line "\n${BOLD}Current Mac${RESET}" if command -v brew >/dev/null 2>&1; then success 'Homebrew is already installed' else note '→ Would install Homebrew' fi local package for package in "${FORMULAE[@]}"; do if command -v brew >/dev/null 2>&1 && brew list --formula "$package" >/dev/null 2>&1; then success "${package} is already installed" else note "→ Would install ${package}" fi done if is_cask_ready orbstack '/Applications/OrbStack.app' docker; then success 'OrbStack is already installed' else note '→ Would install OrbStack' fi if is_cask_ready gcloud-cli '/opt/homebrew/share/google-cloud-sdk' gcloud; then success 'Google Cloud CLI is already installed' else note '→ Would install Google Cloud CLI' fi if [[ "$SHOULD_INSTALL_TEAM_APPS" == false ]]; then note '→ Team applications skipped by option' else local app local label local marker for app in slack linear figma bruno google-chrome visual-studio-code raycast; do case "$app" in slack) label='Slack' marker='/Applications/Slack.app' ;; linear) label='Linear' marker='/Applications/Linear.app' ;; figma) label='Figma' marker='/Applications/Figma.app' ;; bruno) label='Bruno' marker='/Applications/Bruno.app' ;; google-chrome) label='Google Chrome' marker='/Applications/Google Chrome.app' ;; visual-studio-code) label='Visual Studio Code' marker='/Applications/Visual Studio Code.app' ;; raycast) label='Raycast' marker='/Applications/Raycast.app' ;; esac if is_cask_ready "$app" "$marker"; then success "${label} is already installed" else note "→ Would install ${label}" fi done if [[ -d '/Applications/TestFlight.app' ]]; then success 'TestFlight is already installed' else note '→ Would install TestFlight (Mac App Store authentication may be required)' fi fi local installed_cli_version='' if command -v popina >/dev/null 2>&1; then installed_cli_version="$(popina --version 2>/dev/null || true)" fi if [[ "$installed_cli_version" == "$POPINA_CLI_VERSION" ]]; then success "Popina CLI ${installed_cli_version} is already installed" elif [[ -n "$installed_cli_version" ]]; then note "→ Would install Popina CLI ${POPINA_CLI_VERSION} (currently ${installed_cli_version})" else note "→ Would install Popina CLI ${POPINA_CLI_VERSION}" fi if [[ -d "$WORKSPACE_DIR" ]]; then success "Workspace already exists at ${WORKSPACE_DIR}" elif [[ "$SHOULD_INITIALIZE_PROJECT" == false ]]; then note '→ Workspace creation skipped by option' else note "→ Would offer to create ${WORKSPACE_DIR}" fi } load_homebrew_environment() { if [[ -x /opt/homebrew/bin/brew ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [[ -x /usr/local/bin/brew ]]; then eval "$(/usr/local/bin/brew shellenv)" fi } ensure_homebrew() { load_homebrew_environment if command -v brew >/dev/null 2>&1; then success "Homebrew is already installed" return fi has_tty || fail 'Homebrew installation requires an interactive terminal.' step 'Installing Homebrew' note 'macOS may ask for your administrator password.' if ! /usr/bin/sudo -v < /dev/tty; then fail 'Homebrew requires administrator access on this Mac.' fi NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" load_homebrew_environment command -v brew >/dev/null 2>&1 || fail 'Homebrew installed, but brew is not available on PATH.' success 'Homebrew installed' } brew_install() { HOMEBREW_NO_ASK=1 HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ brew install --quiet "$@" } configure_homebrew_shell() { local brew_executable local profile="$HOME/.zprofile" local shellenv_line brew_executable="$(command -v brew)" shellenv_line="eval \"\$(${brew_executable} shellenv)\"" touch "$profile" if grep -Fq "$brew_executable shellenv" "$profile"; then success 'Homebrew is already configured for new terminals' else printf '\n# Homebrew\n%s\n' "$shellenv_line" >> "$profile" success 'Homebrew added to ~/.zprofile' fi } install_formulae() { local missing=() local package for package in "${FORMULAE[@]}"; do if brew list --formula "$package" >/dev/null 2>&1; then success "${package} is already installed" else missing+=("$package") fi done if ((${#missing[@]} > 0)); then step 'Installing command-line tools' brew_install "${missing[@]}" fi } is_cask_ready() { local cask=$1 local marker=$2 local command_name=${3:-} brew list --cask "$cask" >/dev/null 2>&1 && return 0 [[ -e "$marker" ]] && return 0 [[ -n "$command_name" ]] && command -v "$command_name" >/dev/null 2>&1 && return 0 return 1 } install_required_apps() { local missing=() if is_cask_ready orbstack '/Applications/OrbStack.app' docker; then success 'OrbStack is already installed' else missing+=(orbstack) fi if is_cask_ready gcloud-cli '/opt/homebrew/share/google-cloud-sdk' gcloud; then success 'Google Cloud CLI is already installed' else missing+=(gcloud-cli) fi if ((${#missing[@]} > 0)); then step 'Installing local development services' brew_install --cask "${missing[@]}" fi } install_team_apps() { local missing=() if is_cask_ready slack '/Applications/Slack.app'; then success 'Slack is already installed' else missing+=(slack) fi if is_cask_ready linear '/Applications/Linear.app'; then success 'Linear is already installed' else missing+=(linear) fi if is_cask_ready figma '/Applications/Figma.app'; then success 'Figma is already installed' else missing+=(figma) fi if is_cask_ready bruno '/Applications/Bruno.app'; then success 'Bruno is already installed' else missing+=(bruno) fi if is_cask_ready google-chrome '/Applications/Google Chrome.app'; then success 'Google Chrome is already installed' else missing+=(google-chrome) fi if is_cask_ready visual-studio-code '/Applications/Visual Studio Code.app' code; then success 'Visual Studio Code is already installed' else missing+=(visual-studio-code) fi if is_cask_ready raycast '/Applications/Raycast.app'; then success 'Raycast is already installed' else missing+=(raycast) fi if ((${#missing[@]} > 0)); then step 'Installing team applications' brew_install --cask "${missing[@]}" fi install_testflight } open_testflight_store_page() { if command -v mas >/dev/null 2>&1 && mas open "$TESTFLIGHT_APP_ID" >/dev/null 2>&1; then return fi open "macappstore://apps.apple.com/app/id${TESTFLIGHT_APP_ID}" >/dev/null 2>&1 || true } install_testflight() { if [[ -d '/Applications/TestFlight.app' ]]; then success 'TestFlight is already installed' return fi step 'Installing TestFlight from the Mac App Store' note 'macOS may ask you to sign in to the App Store and approve the download.' if ! command -v mas >/dev/null 2>&1 && ! brew_install mas; then HAS_MANUAL_APP_STEP=true warn 'Could not install the Mac App Store helper. Opening TestFlight in the App Store instead.' open_testflight_store_page return fi if mas get "$TESTFLIGHT_APP_ID"; then success 'TestFlight installed' return fi HAS_MANUAL_APP_STEP=true warn 'TestFlight needs attention in the Mac App Store.' note 'Finish its installation there, then rerun this installer. Completed steps will be skipped.' open_testflight_store_page } configure_shell() { local mise_executable local profile="$HOME/.zprofile" local zshrc="$HOME/.zshrc" local activation_line local shims_line mise_executable="$(command -v mise)" activation_line="eval \"\$(${mise_executable} activate zsh)\"" shims_line="eval \"\$(${mise_executable} activate zsh --shims)\"" touch "$profile" if grep -Fq 'mise activate zsh --shims' "$profile"; then success 'mise shims are already available to login shells' else printf '\n# Popina developer toolchain\n%s\n' "$shims_line" >> "$profile" success 'mise shims added to ~/.zprofile' fi touch "$zshrc" if grep -Fq 'mise activate zsh' "$zshrc"; then success 'mise is already activated in zsh' else printf '\n# Popina developer toolchain\n%s\n' "$activation_line" >> "$zshrc" success 'mise activation added to ~/.zshrc' fi } install_popina_cli() { step 'Installing the official Popina CLI' note 'The @popina/cli package will clone and configure the Popina project in Phase 3.' note "Package: @popina/cli@${POPINA_CLI_VERSION}" mise use --quiet --global --yes "node@${NODE_VERSION}" "npm:pnpm@${PNPM_VERSION}" mise exec --quiet "node@${NODE_VERSION}" -- npm install --global --no-audit --no-fund --loglevel=error "@popina/cli@${POPINA_CLI_VERSION}" mise reshim --quiet local installed_version installed_version="$(mise exec --quiet "node@${NODE_VERSION}" -- popina --version)" success "Popina CLI ${installed_version} is ready" } start_orbstack() { if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then success 'Docker-compatible runtime is running' return fi if open -Ra OrbStack >/dev/null 2>&1; then step 'Starting OrbStack' open -a OrbStack note 'Complete the first-launch prompts if OrbStack asks, then return here.' local attempt for ((attempt = 1; attempt <= 30; attempt += 1)); do if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then success 'OrbStack is running' return fi sleep 2 done fi warn 'OrbStack is installed but not ready yet. Open it before choosing database setup.' } prepare_workspace() { prompt_workspace [[ ! -e "$WORKSPACE_DIR" || -d "$WORKSPACE_DIR" ]] || fail "${WORKSPACE_DIR} exists but is not a directory." mkdir -p -- "$WORKSPACE_DIR" [[ -w "$WORKSPACE_DIR" ]] || fail "The workspace is not writable: ${WORKSPACE_DIR}." success "Workspace ready at ${WORKSPACE_DIR}" } run_project_setup() { has_tty || fail 'popina init needs an interactive terminal. Re-run without --skip-project from Terminal.' step 'Starting the official Popina project setup' note "@popina/cli will clone pragma-web into ${WORKSPACE_DIR}, install its mise tools, fetch secrets, and prepare the database." ( cd "$WORKSPACE_DIR" mise exec "node@${NODE_VERSION}" -- popina init < /dev/tty ) } print_line "${CYAN}${BOLD} Popina${RESET} ${MUTED}developer setup v${INSTALLER_VERSION}${RESET}" print_line "${MUTED} Web profile · macOS${RESET}" ensure_macos print_plan if [[ "$DRY_RUN" == true ]]; then print_dry_run_status print_line "\n${GREEN}${BOLD}Dry run complete.${RESET} No changes were made." exit 0 fi confirm 'Continue with this setup?' yes || { note 'Setup canceled. No changes were made.' exit 0 } phase 1 'Prepare Your Mac' ensure_homebrew configure_homebrew_shell install_formulae install_required_apps if [[ "$SHOULD_INSTALL_TEAM_APPS" == ask ]]; then if confirm 'Install Slack, Linear, Figma, Bruno, Chrome, VS Code, Raycast, and TestFlight?' yes; then SHOULD_INSTALL_TEAM_APPS=true else SHOULD_INSTALL_TEAM_APPS=false fi fi if [[ "$SHOULD_INSTALL_TEAM_APPS" == true ]]; then install_team_apps else note 'Skipping optional team applications.' fi configure_shell install_popina_cli phase 2 'Create Your Workspace' print_line 'A workspace is the parent folder where you keep your code repositories.' print_line 'We recommend ~/Workspace so Popina and future repositories stay together.' print_line 'You can choose ~/Code, Code, or any other writable directory.' if [[ "$SHOULD_INITIALIZE_PROJECT" == ask ]]; then if confirm 'Create a workspace and continue with project setup?' yes; then SHOULD_INITIALIZE_PROJECT=true else SHOULD_INITIALIZE_PROJECT=false fi fi if [[ "$SHOULD_INITIALIZE_PROJECT" == true ]]; then prepare_workspace phase 3 'Set Up Popina' start_orbstack run_project_setup else expand_workspace_path print_line "\n${GREEN}${BOLD}Your Mac is ready for Popina.${RESET}" print_line "Run this when you want to create the project:" print_line " ${CYAN}mkdir -p \"${WORKSPACE_DIR}\" && cd \"${WORKSPACE_DIR}\" && popina init${RESET}" fi if [[ "$HAS_MANUAL_APP_STEP" == true ]]; then print_line "\n${YELLOW}${BOLD}Setup completed with one follow-up.${RESET} Finish installing TestFlight from the Mac App Store." else print_line "\n${GREEN}${BOLD}Setup complete.${RESET}" fi print_line "\n${BOLD}Reload this terminal before using Popina:${RESET}" print_line " ${CYAN}exec zsh -l${RESET}"