mobux 0.1.9

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
#!/usr/bin/env bash
#
# bin/setup-twa — install the toolchain mobux needs to build the TWA APK.
#
# Idempotent. User-local installs only — no sudo. Each tool: detect, skip if
# present at adequate version, otherwise install.
#
# Installs/ensures:
#   - SDKMAN at ~/.sdkman
#   - JDK 17 (Temurin) via SDKMAN
#   - nvm at ~/.nvm
#   - Node LTS via nvm
#   - Android command-line tools at ~/.android/cmdline-tools/latest
#   - Android SDK platform-tools, build-tools, platform (compileSdk 34)
#   - SDK licenses accepted non-interactively
#   - @bubblewrap/cli installed via npm with prefix ~/.local
#
# At the end, prints PATH/env hints for any user-local installs.
set -euo pipefail

# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

log() { printf '\033[1;34m[setup-twa]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[setup-twa] WARN:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31m[setup-twa] FAIL:\033[0m %s\n' "$*" >&2; exit 1; }

SCRIPT_PATH="${BASH_SOURCE[0]}"
chmod +x "${SCRIPT_PATH}" 2>/dev/null || true

# Required CLI tools that must be present on the host. None of these can
# be installed without sudo, so fail loudly if missing.
require_host_tool() {
  local tool="$1"
  if ! command -v "${tool}" >/dev/null 2>&1; then
    fail "${tool} is required but not installed. Install it via your OS package manager and re-run."
  fi
}

require_host_tool curl
require_host_tool unzip
require_host_tool zip

# JDK 17 download for Android SDK licenses needs Java; we install JDK via
# SDKMAN below. The Android SDK manager itself needs zip/unzip.

# ---------------------------------------------------------------------------
# 1. SDKMAN + JDK 17
# ---------------------------------------------------------------------------

SDKMAN_DIR="${SDKMAN_DIR:-${HOME}/.sdkman}"
# Preferred JDK identifier. SDKMAN candidate names rotate as new patch releases
# come out, so on miss we fall back to whatever 17.x Temurin SDKMAN offers.
JDK_VERSION="17.0.13-tem"

ensure_sdkman() {
  if [ -s "${SDKMAN_DIR}/bin/sdkman-init.sh" ]; then
    log "SDKMAN already installed at ${SDKMAN_DIR}"
    return 0
  fi

  log "Installing SDKMAN to ${SDKMAN_DIR}"
  # SDKMAN install script honors $SDKMAN_DIR if exported.
  export SDKMAN_DIR
  curl -s "https://get.sdkman.io?rcupdate=false" | bash

  if [ ! -s "${SDKMAN_DIR}/bin/sdkman-init.sh" ]; then
    fail "SDKMAN install reported success but ${SDKMAN_DIR}/bin/sdkman-init.sh is missing."
  fi
}

ensure_jdk17() {
  # Source SDKMAN into this shell.
  set +u  # SDKMAN init script touches unset vars
  # shellcheck disable=SC1091
  . "${SDKMAN_DIR}/bin/sdkman-init.sh"
  set -u

  # SDKMAN installs each candidate as a directory under candidates/java/.
  # Detect any 17.x install by directory listing — robust against changes in
  # `sdk list` output formatting.
  local java_dir="${SDKMAN_DIR}/candidates/java"
  local installed=""
  if [ -d "${java_dir}" ]; then
    installed="$(find "${java_dir}" -mindepth 1 -maxdepth 1 -type d -name '17.*' \
      -printf '%f\n' 2>/dev/null | head -n1)"
  fi

  if [ -n "${installed}" ]; then
    log "JDK 17 already installed via SDKMAN (${installed})"
  else
    log "Installing JDK ${JDK_VERSION} via SDKMAN"
    # SDKMAN's internal install scripts reference unset positional params, so
    # `sdk install` blows up under `set -u`. And `yes n | sdk install` exits
    # `yes` on SIGPIPE which `pipefail` reports as a failure even when the
    # install itself succeeded. Relax both around SDKMAN calls.
    set +u
    set +o pipefail
    # `sdk install` asks "Do you want to set <ver> as default?" — answer no.
    if ! yes n | sdk install java "${JDK_VERSION}"; then
      # Fallback: pick the newest 17.x Temurin that SDKMAN currently offers.
      local fallback
      fallback="$(sdk list java 2>/dev/null \
        | grep -Eo '17\.[0-9.]+-tem' | sort -u | tail -n1 || true)"
      if [ -z "${fallback}" ]; then
        set -u
        set -o pipefail
        fail "Could not install JDK 17 via SDKMAN (no 17.x-tem candidate found)."
      fi
      log "Pinned JDK ${JDK_VERSION} not available; falling back to ${fallback}"
      yes n | sdk install java "${fallback}"
    fi
    set -u
    set -o pipefail
    # Refresh detection after install.
    installed="$(find "${java_dir}" -mindepth 1 -maxdepth 1 -type d -name '17.*' \
      -printf '%f\n' 2>/dev/null | head -n1)"
  fi

  # Use the installed JDK 17 in this shell so subsequent steps see it.
  if [ -n "${installed}" ]; then
    sdk use java "${installed}" >/dev/null || true
  fi

  if ! command -v javac >/dev/null 2>&1; then
    warn "javac not on PATH after JDK install. SDKMAN may need a fresh shell."
  else
    log "javac: $(javac -version 2>&1)"
  fi
}

# ---------------------------------------------------------------------------
# 2. nvm + Node LTS
# ---------------------------------------------------------------------------

NVM_DIR="${NVM_DIR:-${HOME}/.nvm}"

ensure_nvm() {
  if [ -s "${NVM_DIR}/nvm.sh" ]; then
    log "nvm already installed at ${NVM_DIR}"
    return 0
  fi

  log "Installing nvm to ${NVM_DIR}"
  export NVM_DIR
  # Pin a known-good nvm release; update infrequently.
  PROFILE=/dev/null curl -o- \
    https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

  if [ ! -s "${NVM_DIR}/nvm.sh" ]; then
    fail "nvm install reported success but ${NVM_DIR}/nvm.sh is missing."
  fi
}

ensure_node_lts() {
  # nvm's shell functions reference unset vars, same as SDKMAN. Keep -u off
  # for the whole function body and restore on every exit path.
  set +u
  # shellcheck disable=SC1091
  . "${NVM_DIR}/nvm.sh"

  # `nvm which lts/*` exits 0 and prints the path if any LTS is installed.
  if nvm which 'lts/*' >/dev/null 2>&1; then
    nvm use --lts >/dev/null 2>&1 || true
    log "Node LTS already installed via nvm: $(node --version 2>/dev/null || echo unknown)"
    set -u
    return 0
  fi

  log "Installing Node LTS via nvm"
  nvm install --lts
  nvm use --lts >/dev/null
  log "Node: $(node --version)"
  set -u
}

# ---------------------------------------------------------------------------
# 3. Android command-line tools + SDK packages + license acceptance
# ---------------------------------------------------------------------------

ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${HOME}/.android}"
CMDLINE_TOOLS_DIR="${ANDROID_SDK_ROOT}/cmdline-tools/latest"

# Bubblewrap defaults: API 34, build-tools 34.0.0. Pinned here for reproducibility.
ANDROID_API_LEVEL="34"
ANDROID_BUILD_TOOLS="34.0.0"

# Android command-line tools — Linux x86_64 build. Update version periodically.
# Keep the URL exactly as Google publishes; pin the build number.
CMDLINE_TOOLS_VERSION="11076708"
CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"

ensure_android_cmdline_tools() {
  if [ -x "${CMDLINE_TOOLS_DIR}/bin/sdkmanager" ]; then
    log "Android cmdline-tools already installed at ${CMDLINE_TOOLS_DIR}"
    return 0
  fi

  log "Installing Android command-line tools to ${CMDLINE_TOOLS_DIR}"
  mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"

  local tmpdir
  tmpdir="$(mktemp -d)"
  trap 'rm -rf "${tmpdir}"' RETURN

  curl -fsSL -o "${tmpdir}/cmdline-tools.zip" "${CMDLINE_TOOLS_URL}"
  unzip -q "${tmpdir}/cmdline-tools.zip" -d "${tmpdir}"

  # The zip extracts to a directory named "cmdline-tools/"; sdkmanager expects
  # to live at <root>/cmdline-tools/latest/.
  if [ ! -d "${tmpdir}/cmdline-tools" ]; then
    fail "Unexpected layout in Android cmdline-tools zip."
  fi
  mv "${tmpdir}/cmdline-tools" "${CMDLINE_TOOLS_DIR}"

  if [ ! -x "${CMDLINE_TOOLS_DIR}/bin/sdkmanager" ]; then
    fail "sdkmanager not found at ${CMDLINE_TOOLS_DIR}/bin/sdkmanager after install."
  fi

  # Bubblewrap's AndroidSdkTools.validatePath() insists on either <SDK>/tools
  # or <SDK>/bin existing. Modern Android SDK installs only have
  # <SDK>/cmdline-tools/latest/bin. Symlink so the validation passes.
  if [ ! -e "${ANDROID_SDK_ROOT}/bin" ]; then
    ln -s cmdline-tools/latest/bin "${ANDROID_SDK_ROOT}/bin"
    log "Created ${ANDROID_SDK_ROOT}/bin -> cmdline-tools/latest/bin (for bubblewrap)"
  fi
}

ensure_android_sdk_packages() {
  local sdkmanager="${CMDLINE_TOOLS_DIR}/bin/sdkmanager"

  if [ ! -x "${sdkmanager}" ]; then
    fail "sdkmanager missing; install Android cmdline-tools first."
  fi

  # sdkmanager wants ANDROID_HOME / ANDROID_SDK_ROOT set in its env.
  export ANDROID_HOME="${ANDROID_SDK_ROOT}"
  export ANDROID_SDK_ROOT

  # Detect already-installed packages by checking the SDK directory layout.
  # This is robust across sdkmanager versions, unlike parsing `--list` output.
  local needed=()
  [ -d "${ANDROID_SDK_ROOT}/platform-tools" ] \
    || needed+=("platform-tools")
  [ -d "${ANDROID_SDK_ROOT}/platforms/android-${ANDROID_API_LEVEL}" ] \
    || needed+=("platforms;android-${ANDROID_API_LEVEL}")
  [ -d "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}" ] \
    || needed+=("build-tools;${ANDROID_BUILD_TOOLS}")

  if [ ${#needed[@]} -eq 0 ]; then
    log "Android SDK packages already installed (platform-tools, platforms;android-${ANDROID_API_LEVEL}, build-tools;${ANDROID_BUILD_TOOLS})"
  else
    log "Installing Android SDK packages: ${needed[*]}"
    # `yes |` exits via SIGPIPE when sdkmanager closes its stdin, which
    # `pipefail` reports as failure even when the install succeeded.
    set +o pipefail
    yes | "${sdkmanager}" --licenses >/dev/null 2>&1 || true
    yes | "${sdkmanager}" "${needed[@]}" >/dev/null
    set -o pipefail
  fi

  # Always (re-)accept licenses; cheap and idempotent.
  log "Accepting Android SDK licenses (non-interactive)"
  set +o pipefail
  yes | "${sdkmanager}" --licenses >/dev/null 2>&1 || true
  set -o pipefail
}

# ---------------------------------------------------------------------------
# 4. @bubblewrap/cli — npm install -g with prefix ~/.local
# ---------------------------------------------------------------------------

NPM_PREFIX="${HOME}/.local"

ensure_bubblewrap() {
  # Make sure node/npm are on PATH (sourced from nvm in this script).
  if ! command -v npm >/dev/null 2>&1; then
    fail "npm is not on PATH. nvm install must have failed."
  fi

  mkdir -p "${NPM_PREFIX}/bin"

  # Use the script-local PATH so the freshly-installed bubblewrap is visible
  # to the version check below.
  export PATH="${NPM_PREFIX}/bin:${PATH}"

  if command -v bubblewrap >/dev/null 2>&1; then
    local current
    current="$(bubblewrap --version 2>/dev/null | head -n1 || true)"
    log "@bubblewrap/cli already installed (${current:-unknown version})"
    return 0
  fi

  log "Installing @bubblewrap/cli with npm prefix ${NPM_PREFIX}"
  # Set npm prefix only for this install so the user's global npm config is
  # untouched.
  npm install -g --prefix "${NPM_PREFIX}" @bubblewrap/cli

  if ! command -v bubblewrap >/dev/null 2>&1; then
    warn "bubblewrap installed but not on PATH. Check ${NPM_PREFIX}/bin."
  else
    log "bubblewrap: $(bubblewrap --version 2>/dev/null | head -n1)"
  fi
}

# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------

log "mobux TWA toolchain setup starting"

ensure_sdkman
ensure_jdk17
ensure_nvm
ensure_node_lts
ensure_android_cmdline_tools
ensure_android_sdk_packages
ensure_bubblewrap

# ---------------------------------------------------------------------------
# Final PATH / env hints
# ---------------------------------------------------------------------------

cat <<EOF

==========================================================================
TWA toolchain setup complete.

All tools are installed user-locally. Add the following to your shell rc
(~/.bashrc or ~/.zshrc) so they're available in new shells:

  # SDKMAN (Java)
  export SDKMAN_DIR="${SDKMAN_DIR}"
  [ -s "\$SDKMAN_DIR/bin/sdkman-init.sh" ] && . "\$SDKMAN_DIR/bin/sdkman-init.sh"

  # nvm (Node)
  export NVM_DIR="${NVM_DIR}"
  [ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh"

  # Android SDK
  export ANDROID_HOME="${ANDROID_SDK_ROOT}"
  export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT}"
  export PATH="\$ANDROID_HOME/cmdline-tools/latest/bin:\$ANDROID_HOME/platform-tools:\$PATH"

  # @bubblewrap/cli (npm prefix)
  export PATH="${NPM_PREFIX}/bin:\$PATH"

After updating your rc, open a new shell and verify:
  javac -version
  node --version
  sdkmanager --version
  bubblewrap --version
==========================================================================
EOF