#!/usr/bin/env bash # Download the twg binary and install skills. # # Public installer usage: # bash <(curl -fsSL https://teamwork-graph.atlassian.com/cli/install) # bash <(curl -fsSL https://teamwork-graph.atlassian.com/cli/install) --version 0.3.2 # bash <(curl -fsSL https://teamwork-graph.atlassian.com/cli/install) --skip-login # bash <(curl -fsSL https://teamwork-graph.atlassian.com/cli/install) --skip-skills # bash <(curl -fsSL https://teamwork-graph.atlassian.com/cli/install) --force # # Statlas setup mode is rendered from this file by bin/release/render-statlas-setup.sh. # bash setup.sh --local --yes --skip-login # # Environment variables: # TWG_VERSION=0.5.0 Alternative to --version flag (e.g. for scripted installs) set -euo pipefail INSTALLER_MODE="${TWG_INSTALLER_MODE:-public}" PUBLIC_CDN_BASE_URL="${TWG_INSTALL_BASE_URL:-https://teamwork-graph.atlassian.com/cli}" STATLAS_BASE_URL="${TWG_INSTALL_BASE_URL:-https://statlas.prod.atl-paas.net/community-assets-twg/clis}" AGENT_INSTRUCTIONS_URL="${PUBLIC_CDN_BASE_URL}/AGENTS.md" # DTAC CloudFront routes /cli/* to twg-cli-cdn with originPath: /assets, # so /cli/foo maps to /assets/foo on the twg-cli-cdn Bifrost consumer. DEFAULT_VERSION="1.0.1" case "${INSTALLER_MODE}" in public|statlas) ;; *) echo "Error: unsupported installer mode '${INSTALLER_MODE}'." >&2 exit 2 ;; esac # --------------------------------------------------------------------------- # Parse flags # --------------------------------------------------------------------------- SKIP_DOWNLOAD=false SKIP_LOGIN=false SKIP_SKILLS=false WAIT_FOR_PID="" INSTALL_DIR_OVERRIDE="" REQUESTED_VERSION="${TWG_VERSION:-}" FORCE_INSTALL=false GLOBAL_INSTALL=true ASSUME_YES=false while [[ $# -gt 0 ]]; do case "$1" in --global) GLOBAL_INSTALL=true shift ;; --local) GLOBAL_INSTALL=false shift ;; --skip-download) SKIP_DOWNLOAD=true shift ;; -y|--yes) if [[ "${INSTALLER_MODE}" == "statlas" ]]; then ASSUME_YES=true fi shift ;; --skip-login) SKIP_LOGIN=true shift ;; --skip-skills) SKIP_SKILLS=true shift ;; --force) FORCE_INSTALL=true shift ;; --wait-for-pid) if [[ $# -lt 2 ]]; then echo "Error: --wait-for-pid requires a value." >&2 exit 2 fi WAIT_FOR_PID="$2" shift 2 ;; --wait-for-pid=*) WAIT_FOR_PID="${1#--wait-for-pid=}" shift ;; --install-dir) if [[ $# -lt 2 ]]; then echo "Error: --install-dir requires a value." >&2 exit 2 fi INSTALL_DIR_OVERRIDE="$2" shift 2 ;; --install-dir=*) INSTALL_DIR_OVERRIDE="${1#--install-dir=}" if [[ -z "${INSTALL_DIR_OVERRIDE}" ]]; then echo "Error: --install-dir requires a value." >&2 exit 2 fi shift ;; --version) if [[ $# -lt 2 ]]; then echo "Error: --version requires a value." >&2 exit 2 fi REQUESTED_VERSION="$2" shift 2 ;; --version=*) REQUESTED_VERSION="${1#--version=}" shift ;; *) echo "Error: unknown argument '$1'." >&2 exit 2 ;; esac done # --------------------------------------------------------------------------- # Detect platform # --------------------------------------------------------------------------- OS="$(uname -s)" ARCH="$(uname -m)" case "${OS}" in Darwin) PLATFORM="darwin" ;; Linux) PLATFORM="linux" ;; *) echo "Error: unsupported OS '${OS}'. Only macOS and Linux are supported." >&2 exit 1 ;; esac case "${ARCH}" in arm64|aarch64) ARCH_SUFFIX="arm64" ;; x86_64) ARCH_SUFFIX="x64" ;; *) echo "Error: unsupported architecture '${ARCH}'. Only arm64 and x86_64 are supported." >&2 exit 1 ;; esac BINARY_NAME="twg-${PLATFORM}-${ARCH_SUFFIX}" if [[ "${INSTALLER_MODE}" == "statlas" ]]; then if [[ -n "${REQUESTED_VERSION}" ]]; then DOWNLOAD_BASE_URL="${STATLAS_BASE_URL}/${REQUESTED_VERSION}" RELEASE_SELECTOR="version ${REQUESTED_VERSION}" INSTALLED_VERSION_LABEL="${REQUESTED_VERSION}" else DOWNLOAD_BASE_URL="${STATLAS_BASE_URL}/stable" RELEASE_SELECTOR="stable" INSTALLED_VERSION_LABEL="stable" fi DOWNLOAD_BINARY_NAME="${BINARY_NAME}" CHECKSUMS_URL="${DOWNLOAD_BASE_URL}/SHA256SUMS" FINALIZE_SOURCE="release-setup" FINALIZE_SKILLS_SCOPE="picker" if [[ -n "${INSTALL_DIR_OVERRIDE}" ]]; then INSTALL_DIR="${INSTALL_DIR_OVERRIDE}" elif [[ "${GLOBAL_INSTALL}" == "true" ]]; then INSTALL_DIR="${HOME}/.local/bin" else INSTALL_DIR="$(pwd)/bin" fi else if [[ -n "${REQUESTED_VERSION}" ]]; then RELEASE_SELECTOR="version ${REQUESTED_VERSION}" else REQUESTED_VERSION="${DEFAULT_VERSION}" RELEASE_SELECTOR="latest (v${DEFAULT_VERSION})" fi INSTALLED_VERSION_LABEL="${REQUESTED_VERSION}" # Bifrost stores versioned binaries as twg-darwin-arm64-v0.3.2 DOWNLOAD_BINARY_NAME="${BINARY_NAME}-v${REQUESTED_VERSION}" DOWNLOAD_BASE_URL="${PUBLIC_CDN_BASE_URL}" CHECKSUMS_URL="${PUBLIC_CDN_BASE_URL}/SHA256SUMS-v${REQUESTED_VERSION}" FINALIZE_SOURCE="direct-public-installer" FINALIZE_SKILLS_SCOPE="global" INSTALL_DIR="${INSTALL_DIR_OVERRIDE:-${HOME}/.local/bin}" echo "Agent install instructions: ${AGENT_INSTRUCTIONS_URL}" fi TWG_PATH="${INSTALL_DIR}/twg" TWG_BIN_PATH="${INSTALL_DIR}/twg-bin" is_homebrew_twg_path() { case "$1" in /opt/homebrew/bin/twg|/usr/local/bin/twg|/home/linuxbrew/.linuxbrew/bin/twg|*/homebrew/bin/twg|*/Cellar/twg/*) return 0 ;; *) return 1 ;; esac } confirm_direct_over_homebrew() { local active_twg="$1" if [[ "${FORCE_INSTALL}" == "true" ]]; then echo "Warning: continuing direct install even though Homebrew twg is active: ${active_twg}" >&2 return 0 fi if [[ -t 0 ]]; then echo "Homebrew-managed twg is already first on PATH:" >&2 echo " ${active_twg}" >&2 echo "" >&2 echo "Recommended: use 'brew upgrade twg' and avoid a second direct install." >&2 printf "Install a separate direct copy at %s anyway? [y/N] " "${TWG_PATH}" >&2 local reply="" read -r reply case "${reply}" in y|Y|yes|YES) return 0 ;; esac else echo "Error: Homebrew-managed twg is already first on PATH: ${active_twg}" >&2 echo "Run 'brew upgrade twg', or rerun this installer with --force to create a separate direct install." >&2 return 1 fi echo "Cancelled direct install. Existing Homebrew install was left unchanged." >&2 return 1 } check_install_manager_conflict() { local active_twg="" active_twg="$(command -v twg 2>/dev/null || true)" if [[ -n "${active_twg}" && "${active_twg}" != "${TWG_PATH}" ]] && is_homebrew_twg_path "${active_twg}"; then confirm_direct_over_homebrew "${active_twg}" fi } if [[ "${INSTALLER_MODE}" == "public" ]]; then check_install_manager_conflict fi write_twg_wrapper() { cat > "${TWG_PATH}" <<'EOF' #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" for name in "${!BUN_@}"; do unset "$name" done exec "$SCRIPT_DIR/twg-bin" "$@" EOF chmod 755 "${TWG_PATH}" } detect_shell_profile() { case "${SHELL:-}" in */zsh) echo "${HOME}/.zshrc" ;; */bash) if [[ -f "${HOME}/.bash_profile" ]]; then echo "${HOME}/.bash_profile" else echo "${HOME}/.bashrc" fi ;; *) echo "${HOME}/.zshrc" ;; esac } append_install_dir_to_path_profile() { SHELL_PROFILE="$(detect_shell_profile)" PATH_PROFILE_UPDATED=false PATH_PROFILE_SKIPPED=false local confirm_path="" if [[ "${INSTALLER_MODE}" == "statlas" && "${ASSUME_YES}" != "true" ]]; then printf "%s is not in your PATH. Add it to %s now? [Y/n] " "${INSTALL_DIR}" "${SHELL_PROFILE}" read -r confirm_path case "${confirm_path}" in [nN]*) PATH_PROFILE_SKIPPED=true echo "Skipped shell profile PATH update." return ;; esac fi if [[ -f "${SHELL_PROFILE}" ]] && grep -F "export PATH=\"${INSTALL_DIR}:\$PATH\"" "${SHELL_PROFILE}" >/dev/null 2>&1; then PATH_PROFILE_UPDATED=true return fi if { printf '\n' printf '# Added by Teamwork Graph CLI installer\n' printf 'export PATH="%s:$PATH"\n' "${INSTALL_DIR}" } >> "${SHELL_PROFILE}"; then PATH_PROFILE_UPDATED=true else PATH_PROFILE_SKIPPED=true echo "Warning: could not update ${SHELL_PROFILE}; continuing without a persistent PATH update." >&2 fi } # --------------------------------------------------------------------------- # Pre-flight: ensure curl is available when downloading # --------------------------------------------------------------------------- if [[ "${SKIP_DOWNLOAD}" != "true" ]] && ! command -v curl &>/dev/null; then echo "Error: 'curl' not found on PATH." >&2 exit 127 fi # --------------------------------------------------------------------------- # Confirm installation # --------------------------------------------------------------------------- echo "Detected platform: ${PLATFORM}-${ARCH_SUFFIX}" echo "Requested release: ${RELEASE_SELECTOR}" echo "Will install twg to: ${TWG_PATH}" echo "" if [[ "${SKIP_DOWNLOAD}" == "true" ]]; then echo "Will reuse existing twg at: ${TWG_PATH}" echo "" fi if [[ "${INSTALLER_MODE}" == "statlas" && "${ASSUME_YES}" != "true" ]]; then if [[ "${SKIP_DOWNLOAD}" == "true" ]]; then printf "Reuse existing twg at %s and continue with skills install/login? [Y/n] " "${TWG_PATH}" else printf "Install to %s? [Y/n] " "${INSTALL_DIR}" fi read -r confirm case "${confirm}" in [nN]*) echo "Aborted."; exit 0 ;; esac fi # --------------------------------------------------------------------------- # Download binary or reuse an existing one for local testing # --------------------------------------------------------------------------- mkdir -p "${INSTALL_DIR}" if [[ -n "${WAIT_FOR_PID}" ]]; then while kill -0 "${WAIT_FOR_PID}" 2>/dev/null; do sleep 1 done fi if [[ "${SKIP_DOWNLOAD}" == "true" ]]; then if [[ ! -x "${TWG_BIN_PATH}" && -x "${TWG_PATH}" ]]; then mv "${TWG_PATH}" "${TWG_BIN_PATH}" fi if [[ -x "${TWG_BIN_PATH}" ]]; then write_twg_wrapper fi if [[ ! -x "${TWG_PATH}" || ! -x "${TWG_BIN_PATH}" ]]; then echo "Error: --skip-download requires existing executables at ${TWG_PATH} and ${TWG_BIN_PATH}." >&2 exit 126 fi echo "Skipping download and reusing existing twg at ${TWG_PATH}" else URL="${DOWNLOAD_BASE_URL}/${DOWNLOAD_BINARY_NAME}" CHECKSUMS_PATH="$(mktemp "${TMPDIR:-/tmp}/twg-sha256sums.XXXXXX")" TWG_BIN_TMP="" # initialise before trap so nounset (-u) doesn't fire if mktemp below fails trap 'rm -f "${CHECKSUMS_PATH}" "${TWG_BIN_TMP}"' EXIT # Clean up any stale temp files left by previously interrupted installs. rm -f "${INSTALL_DIR}"/.twg-bin.* 2>/dev/null || true # Download the binary to a temp file in the same directory as the destination # so that the final mv is atomic (same filesystem). Writing directly to the # destination would overwrite the existing binary in-place, which leaves stale # Gatekeeper/AppleSystemPolicy approval state on the inode and causes macOS to # block the new binary from running. TWG_BIN_TMP="$(mktemp "${INSTALL_DIR}/.twg-bin.XXXXXX")" || { echo "Error: could not create temporary file in ${INSTALL_DIR}. Check disk space and permissions." >&2 exit 1 } echo "Downloading ${URL} …" curl -fSL --retry 2 -o "${TWG_BIN_TMP}" "${URL}" echo "Downloading ${CHECKSUMS_URL} -> ${CHECKSUMS_PATH} …" curl -fSL --retry 2 -o "${CHECKSUMS_PATH}" "${CHECKSUMS_URL}" if [[ ! -s "${CHECKSUMS_PATH}" ]]; then echo "Error: checksum file is empty or download failed. Aborting." >&2 exit 1 fi expected_sha="$(awk -v file="${DOWNLOAD_BINARY_NAME}" '{ checksum = $1 name = $2 sub(/^\*/, "", name) sub(/\r$/, "", name) if (name == file) { print checksum; exit } }' "${CHECKSUMS_PATH}")" if [[ -z "${expected_sha}" ]]; then echo "Error: checksum entry for ${DOWNLOAD_BINARY_NAME} not found in ${CHECKSUMS_URL}." >&2 exit 1 fi if command -v sha256sum &>/dev/null; then actual_sha="$(sha256sum "${TWG_BIN_TMP}" | awk '{print $1}')" elif command -v shasum &>/dev/null; then actual_sha="$(shasum -a 256 "${TWG_BIN_TMP}" | awk '{print $1}')" else echo "Error: neither sha256sum nor shasum is available to verify ${DOWNLOAD_BINARY_NAME}." >&2 exit 127 fi if [[ "${actual_sha}" != "${expected_sha}" ]]; then echo "Error: checksum verification failed for ${DOWNLOAD_BINARY_NAME}." >&2 echo "Expected: ${expected_sha}" >&2 echo "Actual: ${actual_sha}" >&2 exit 1 fi chmod +x "${TWG_BIN_TMP}" if [ ! -x "${TWG_BIN_TMP}" ]; then echo "Error: could not make downloaded binary executable." >&2 exit 126 fi # Atomically replace the destination — mv on the same filesystem is a single # rename() syscall, so the old binary is never partially overwritten. mv "${TWG_BIN_TMP}" "${TWG_BIN_PATH}" TWG_BIN_TMP="" # moved into place; trap's rm -f on empty string is a no-op write_twg_wrapper # On macOS, remove quarantine attribute added to downloaded files. if [[ "${OS}" == "Darwin" ]]; then xattr -d com.apple.quarantine "${TWG_PATH}" "${TWG_BIN_PATH}" 2>/dev/null || true fi if [[ "${INSTALLER_MODE}" == "statlas" ]]; then echo "twg installed at ${TWG_PATH}" else echo "twg v${REQUESTED_VERSION} installed at ${TWG_PATH}" fi fi echo "" # --------------------------------------------------------------------------- # Ensure install dir is on PATH # --------------------------------------------------------------------------- PATH_ALREADY_SET=true PATH_PROFILE_UPDATED=false PATH_PROFILE_SKIPPED=false SHELL_PROFILE="" case ":${PATH}:" in *":${INSTALL_DIR}:"*) ;; *) PATH_ALREADY_SET=false export PATH="${INSTALL_DIR}:${PATH}" append_install_dir_to_path_profile ;; esac run_setup_finalize() { local finalize_args finalize_args=( setup finalize --install-method direct-public-installer --install-channel stable --install-dir "${INSTALL_DIR}" --binary-path "${TWG_BIN_PATH}" --installed-version "${INSTALLED_VERSION_LABEL}" --platform "${PLATFORM}" --arch "${ARCH_SUFFIX}" --source "${FINALIZE_SOURCE}" --skills-scope "${FINALIZE_SKILLS_SCOPE}" ) if [[ "${INSTALLER_MODE}" == "public" ]]; then finalize_args+=(--detect-agents --from-clipboard --allow-login-failure) fi if [[ "${INSTALLER_MODE}" == "statlas" && "${ASSUME_YES}" == "true" ]]; then finalize_args+=(--yes) fi if [[ "${SKIP_SKILLS}" == "true" ]]; then finalize_args+=(--skip-skills) fi if [[ "${SKIP_LOGIN}" == "true" ]]; then finalize_args+=(--skip-login) fi if "${TWG_PATH}" setup finalize --help >/dev/null 2>&1; then local finalize_status set +e "${TWG_PATH}" "${finalize_args[@]}" finalize_status=$? set -e if [[ "${finalize_status}" -eq 20 ]]; then LOGIN_FAILED=true elif [[ "${finalize_status}" -ne 0 ]]; then exit "${finalize_status}" fi else echo "Error: downloaded twg ${INSTALLED_VERSION_LABEL} does not support 'twg setup finalize'." >&2 echo "Install twg v0.9.2 or newer, or use an installer published with that older release." >&2 exit 1 fi } # --------------------------------------------------------------------------- # Record install metadata, consent, skills, help cache and login # --------------------------------------------------------------------------- LOGIN_FAILED=false run_setup_finalize # --------------------------------------------------------------------------- # Final summary — always shown last # --------------------------------------------------------------------------- echo "" echo "════════════════════════════════════════════════════════════" echo " ✅ Installed: ${TWG_PATH}" echo "" if [[ "${PATH_ALREADY_SET}" == "false" ]]; then if [[ "${PATH_PROFILE_UPDATED}" == "true" ]]; then echo " ✅ Added ${INSTALL_DIR} to ${SHELL_PROFILE}" echo " Open a new terminal to pick up the PATH update." elif [[ "${PATH_PROFILE_SKIPPED}" == "true" ]]; then echo " ⚠️ ${INSTALL_DIR} was not added to your shell profile." echo " twg is available inside this installer process only until your PATH is updated." fi else echo " ✅ ${INSTALL_DIR} is already in your PATH — you're all set!" fi echo "════════════════════════════════════════════════════════════" echo "" echo "Next steps:" echo " twg doctor" if [[ "${SKIP_LOGIN}" == "true" || "${LOGIN_FAILED}" == "true" ]]; then echo " twg login # (skipped during install)" fi if [[ "${SKIP_SKILLS}" == "true" ]]; then echo " twg skills install -g # (skipped during install)" fi