#!/usr/bin/env bash # Codeman Universal Installer # https://github.com/Ark0N/Codeman # # Usage: curl -fsSL https://getcodeman.com/install | bash # curl -fsSL https://getcodeman.com/install | bash -s -- [flags] [subcommand] # # The flow: look at what is already on the machine, ask at most three # questions (how the dashboard is reached, optionally what to call this # machine on your tailnet, whether to run Codeman as a service), then do all # the work unattended and end on the URL, with a QR code for your phone. # # Flags (each has an environment-variable twin, listed below): # --tailscale | --lan | --local How the dashboard is reached (question 1) # --name | --no-rename Rename this machine on the tailnet / never ask (question 2) # --service | --run | --no-start What to do at the end (question 3) # --yes, -y Take every default; still waits on a Tailscale login URL # --password

Dashboard password (visible in `ps`; prefer CODEMAN_PASSWORD) # --port Port Codeman listens on (default 3000) # --no-admin Never use sudo or Homebrew: download Node.js and tmux into # the home folder instead (chosen by itself when this account # has no administrator rights) # --ai-cli |none Which AI CLI to install when none is found, without the menu # (the ids are the keys of the catalogue below) # --progress Also print machine-readable progress lines (for the # desktop app; see progress_phase) # --help, -h Print this text # # Environment variables: # CODEMAN_NONINTERACTIVE=1 - Skip all prompts and accept their defaults # (CI/automation). Required for headless runs # that need system changes (sudo package # installs, AI CLI download); without it those # steps abort instead of running silently. # Never installs Tailscale, never renames. # CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app) # CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt # CODEMAN_NODE_VERSION - Node.js major version to install (default: 22) # CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman) # CODEMAN_BRANCH - Git branch to install (default: master) # CODEMAN_HOST - Preset the network binding and skip the prompt # (0.0.0.0 for LAN access, 127.0.0.1 for local-only) # CODEMAN_PASSWORD - Preset the dashboard password # CODEMAN_PORT - Port Codeman listens on (default 3000); written # into the service and used as the serve target # CODEMAN_TAILSCALE=1 - Preset the Tailscale choice: bind loopback and # front it with `tailscale serve` HTTPS (never # installs Tailscale in non-interactive runs) # CODEMAN_TAILSCALE_NAME - Rename this machine on the tailnet (same as --name) # CODEMAN_NO_ADMIN - 1: install without administrator rights (same as # --no-admin); 0: always use system packages, even # when this account looks like it has no such rights # CODEMAN_TARBALL_URL - Source archive (.tar.gz) to install from when no # working git is available (default: the GitHub # archive of CODEMAN_REPO_URL at CODEMAN_BRANCH) # CODEMAN_AI_CLI - Same as --ai-cli # CODEMAN_PROGRESS=1 - Same as --progress # # Subcommands: # install.sh update - Update an existing install # install.sh uninstall - Remove services, symlinks and (optionally) data # install.sh tailscale - Set up (or repair) Tailscale HTTPS access for an existing install # install.sh name [] - Rename this machine on your tailnet (default: codeman-) # install.sh status - Print the URLs, the QR code and how to manage the service # install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel # install.sh preflight - Print what is on this machine as JSON; changes nothing set -euo pipefail # ============================================================================ # Configuration # ============================================================================ INSTALL_DIR="${CODEMAN_INSTALL_DIR:-$HOME/.codeman/app}" # "$INSTALL_DIR.new" and ".old" must sit beside the install: with a trailing # slash they land inside it, and every archive update fails at the final swap. while [[ "$INSTALL_DIR" == */ && "$INSTALL_DIR" != "/" ]]; do INSTALL_DIR="${INSTALL_DIR%/}" done # `docker run -u` gives a process no USER, and set -u turns every message that # prints it into a crash. USER="${USER:-$(id -un 2>/dev/null || true)}" REPO_URL="${CODEMAN_REPO_URL:-https://github.com/Ark0N/Codeman.git}" BRANCH="${CODEMAN_BRANCH:-master}" MIN_NODE_VERSION=18 TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}" NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}" SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}" # Install without administrator rights (see "No-admin route" below): 1 = on # (--no-admin), 0 = never, empty = decided from what this account may do # (resolve_admin_mode). NO_ADMIN_REASON is the one-line "why" shown to the user. NO_ADMIN="${CODEMAN_NO_ADMIN:-}" NO_ADMIN_REASON="" # The one reason that can be wrong, so the messages name a way back for it # (no_admin_override_hint). NO_ADMIN_UNCONFIRMED="could not confirm that this account may use sudo" # Where the no-admin route keeps what it downloads (Node.js, tmux). Inside the # data directory, so one folder holds everything Codeman put on the machine. RUNTIME_DIR="$HOME/.codeman/runtime" # The tmux project's own binaries (https://github.com/tmux/tmux-builds). It # publishes no checksum file, so the SHA-256 of every asset of this version is # pinned in tmux_build_sha256 and a download that does not match is refused. # Bump the version and all four digests together. TMUX_BUILD_VERSION="3.7c" # Written into the install directory when the source came from an archive # instead of a git clone. The in-app updater reads the same file name # (ARCHIVE_INSTALL_MARKER in src/web/self-update.ts) to say how such an install # updates; test/install-sh-invariants.test.ts pins the two to each other. SOURCE_MARKER=".install-source" # Set while a fresh source tree is being built beside an existing archive # install (install_or_update_repo); finish_source_swap moves it into place once # the build has succeeded. SOURCE_STAGING="" # Network binding chosen during install (choose_network_binding). Empty # BIND_HOST means "not chosen" (e.g. the update path) and falls back to the # server's own loopback default. BIND_HOST="" BIND_PASSWORD="" BIND_ACK="0" # Binding found in an already-installed service (read_existing_binding), used # so updates and re-installs preserve the user's previous choice instead of # silently loosening it to the new network-access default. EXISTING_FOUND="0" EXISTING_HOST="" EXISTING_PASSWORD="" EXISTING_ACK="0" # Tailscale serve URL configured or detected during this run # (tailscale_apply / detect_tailscale_serve_url). Empty when the Tailscale path # was not taken or not completed. TAILSCALE_SERVE_URL="" # Set to 1 when serve commands must go through sudo because granting the user # tailscale "operator" rights failed (ensure_tailscale_operator). TS_NEED_ROOT="0" # Tailscale decisions taken in the question phase (tailscale_prepare) and # applied after the build (tailscale_apply). TS_READY=1 means preflight passed # (installed, logged in, HTTPS certs on) and a serve shape was chosen. # TS_SERVE_MODE: keep (our mapping already exists), root (https://), # path (https:///codeman, when :443 already belongs to another app), # port (https://:), replace (take :443 over). TS_READY="0" TS_SERVE_MODE="" TS_SERVE_PATH="/codeman" TS_SERVE_PORT="8443" # --name / CODEMAN_TAILSCALE_NAME, and --no-rename. TS_NAME="${CODEMAN_TAILSCALE_NAME:-}" TS_NO_RENAME="0" # Set to 1 when a rename took our serve mapping down (it is keyed by the old # name), so the caller knows to re-add it and never adds one that was not there. TS_MAPPING_REMOVED_BY_RENAME="0" # Set by the INT trap that is armed only while ensure_tailnet_https polls. TS_HTTPS_POLL_INTERRUPTED="0" # Where a rename is recorded so uninstall can offer to undo it (tailscaled does # not remember previous names). TS_RENAME_RECORD="$HOME/.codeman/tailscale-rename" # Sub-path Codeman is mounted under ('' for the root). Set by # tailscale_choose_mapping (path mode) or read back from the service file. BIND_BASE_URL="" EXISTING_BASE_URL="" EXISTING_VERSION="" # Answer presets from flags. LAUNCH_PRESET: 1 = run now, 2 = service, 3 = do # not start. ASSUME_YES=1 (--yes) takes every prompt's default but keeps the # terminal (a Tailscale login URL still waits for a human), unlike # CODEMAN_NONINTERACTIVE, which is the CI contract and never installs Tailscale. LAUNCH_PRESET="" ASSUME_YES="0" # Set by parse_flags when a flag asks to change how an existing install is # reached or run, so a bare re-run takes the full flow instead of a quiet update. RECONFIGURE="0" # --ai-cli / CODEMAN_AI_CLI: answers the AI CLI menu, with a catalogue id or # "none". Empty means ask. Consent comes with the flag, so unlike the # CODEMAN_NONINTERACTIVE default it installs exactly what was named. AI_CLI_CHOICE="${CODEMAN_AI_CLI:-}" # --progress / CODEMAN_PROGRESS=1: see progress_phase. PROGRESS="${CODEMAN_PROGRESS:-0}" SUBCOMMAND="" SUBCOMMAND_ARG="" # Answers to question 3 (choose_launch_mode): LAUNCH_CHOICE 1/2/3 as above, # SERVICE_TYPE systemd | launchd | launchd-daemon (a foreign daemon we left # alone) | empty (no service manager here). LAUNCH_CHOICE="3" SERVICE_TYPE="" # Everything the unattended steps print goes here; the terminal gets one line # per step and the tail of this file on failure. LOG_FILE="$HOME/.codeman/install.log" SUDO_KEEPALIVE_PID="" SPINNER_PID="" # Set once the work phase (clone/build) has begun; gates the cleanup trap's # "partial installation may remain" advice. CURRENT_STEP names whatever the # installer was doing when a `set -e` failure ends it (a vendor installer that # times out otherwise leaves only its own last line on screen). INSTALL_STARTED="0" CURRENT_STEP="" # What preflight_detect found: the missing system packages as prose, and the # Tailscale state (absent | installed | connected | serving). MISSING_PKGS="" TS_STATE="absent" # puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its # ~150MB chrome-headless-shell download is never needed to build or run Codeman. # Skipping it avoids a slow download and a fatal install failure when a prior # download left a corrupt cache (folder present, executable missing). Respect an # explicit caller override so contributors can still fetch the browser if needed. export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}" # >>> BEGIN GENERATED CLI CATALOGUE # Generated from src/config/cli-registry/stock.ts by scripts/generate-cli-catalog.mts. # Do not edit by hand: run `npm run generate:cli-catalog` and commit the result. # # Parallel indexed arrays, bash 3.2 safe (no associative arrays, no nameref, no mapfile). # The variable-length lists use OFFSET/LENGTH windows into one flat array rather than a # delimiter, so a $HOME containing a space needs no IFS handling and an entry with nothing # to contribute (shell has no binaries) gets length 0 and is simply never iterated. # # ⚠️ TRUST BOUNDARY: CLI_CMD_LINUX/CLI_CMD_DARWIN are the ONLY source of a command this # script will ever execute, and they arrive embedded in this file — same TLS fetch, same # commit as the script itself. Nothing fetched at install time is ever executed; there is # no network refresh of these arrays. See cli_catalog_select_platform below. CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot') CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot') CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1) CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0) CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli') CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'npm install -g @github/copilot') CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'npm install -g @github/copilot') CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot') CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9) CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1) CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot") CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47) CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5) # <<< END GENERATED CLI CATALOGUE # ============================================================================ # Color Output # ============================================================================ setup_colors() { # Check if terminal supports colors if [[ -t 1 ]] && [[ -n "${TERM:-}" ]] && command -v tput &>/dev/null; then local ncolors ncolors=$(tput colors 2>/dev/null || echo 0) if [[ "$ncolors" -ge 8 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' return fi fi # No color support RED='' GREEN='' YELLOW='' BLUE='' CYAN='' MAGENTA='' BOLD='' DIM='' NC='' } setup_colors # ============================================================================ # Output Helpers # ============================================================================ info() { echo -e "${BLUE}==>${NC} ${BOLD}$1${NC}" } success() { echo -e "${GREEN}==>${NC} ${BOLD}$1${NC}" } warn() { echo -e "${YELLOW}Warning:${NC} $1" >&2 } error() { echo -e "${RED}Error:${NC} $1" >&2 if [[ "$PROGRESS" == "1" ]]; then printf '@@codeman error %s\n' "${1//$'\n'/ }" >&2 fi } # progress_phase : tell the desktop app which part of the install has # begun. The protocol is one line, `@@codeman phase `, for each of # runtime, agent, source, packages, build, service and done, always in that # order, and `@@codeman error ` for each error() above. A phase runs until # the next one starts; a run that ends without `done` failed. A phase with # nothing to do may be skipped. The app ignores a name it does not know, so a # new phase needs no new app, but `done` must stay the last thing main() reports. # On stderr, because this can be reached inside a $( ) that captures stdout. progress_phase() { if [[ "$PROGRESS" == "1" ]]; then printf '@@codeman phase %s\n' "$1" >&2 fi return 0 } die() { error "$1" exit 1 } # Security notice — printed at the very end of install/update so it is the last # thing the user sees. Adapts to the binding chosen during install; the update # path (BIND_HOST empty) gets the generic text. print_security_notice() { echo "" if [[ "$BIND_HOST" == "0.0.0.0" && -z "$BIND_PASSWORD" ]]; then echo -e " ${RED}${BOLD}============================================================${NC}" echo -e " ${RED}${BOLD} WARNING: NETWORK ACCESS WITHOUT A PASSWORD${NC}" echo -e " ${RED}${BOLD}============================================================${NC}" echo -e " ${RED}The dashboard is reachable by EVERY device on your network,${NC}" echo -e " ${RED}and whoever opens it can run commands as ${BOLD}$USER${NC}${RED} through${NC}" echo -e " ${RED}your AI agents. Anyone on your Wi-Fi owns this machine.${NC}" echo "" echo -e " Fix it by setting a password (takes 30 seconds):" echo -e " ${CYAN}•${NC} re-run the installer and choose a password, or" echo -e " ${CYAN}•${NC} add ${CYAN}Environment=CODEMAN_PASSWORD=${NC} to the service" echo -e " Or switch back to local-only: ${CYAN}CODEMAN_HOST=127.0.0.1${NC}" echo -e " ${DIM}Details: docs/security-architecture.md${NC}" elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then echo -e " ${YELLOW}${BOLD}Security:${NC}" echo -e " The dashboard is reachable from your network at port ${CODEMAN_PORT:-3000} and is" echo -e " password-protected (user ${BOLD}admin${NC}). Keep that password strong:" echo -e " whoever logs in can run commands through your agents." echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel." echo -e " ${DIM}Details: docs/security-architecture.md${NC}" else # Loopback bind: when a tailscale serve mapping fronts it, lead with # the actual URL instead of the generic "do ONE of" list. Detection is # dynamic (tailscaled state is the single source of truth). local notice_ts_url="$TAILSCALE_SERVE_URL" if [[ -z "$notice_ts_url" ]]; then notice_ts_url=$(detect_tailscale_serve_url 2>/dev/null) || notice_ts_url="" fi if [[ -n "$notice_ts_url" ]]; then echo -e " ${YELLOW}${BOLD}Security:${NC}" echo -e " Codeman binds ${BOLD}127.0.0.1${NC}, fronted by Tailscale serve:" echo -e " reachable at ${BOLD}$notice_ts_url${NC} (HTTPS, your tailnet only)." echo -e " Tailscale authenticates every device before traffic reaches Codeman." echo -e " ${DIM}Details: docs/security-architecture.md${NC}" else echo -e " ${YELLOW}${BOLD}Security:${NC}" echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default." echo -e " To reach it from another device, do ONE of:" if check_tailscale; then echo -e " ${CYAN}•${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC} ${DIM}(Tailscale is installed here; HTTPS, recommended)${NC}, or" else echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or" fi echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}" echo -e " A non-loopback bind without a password still starts, but warns loudly." echo -e " ${DIM}Details: docs/security-architecture.md${NC}" fi fi echo "" } # ============================================================================ # Cleanup on Failure # ============================================================================ # End the spinner and the sudo keepalive. Called from the EXIT trap, and by # hand right before `exec` in main(): exec replaces this shell WITHOUT running # the trap, and the keepalive keys on $$, which is then the server's pid, so # it would refresh the sudo timestamp for the whole life of the server. stop_background_helpers() { if [[ -n "$SPINNER_PID" ]]; then kill "$SPINNER_PID" 2>/dev/null || true SPINNER_PID="" printf '\r\033[K' >&2 fi if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true SUDO_KEEPALIVE_PID="" fi return 0 } cleanup() { local exit_code=$? stop_background_helpers # The "partial install" advice is only true once the work phase has begun: # a bad flag or a refused question exits before anything was written. if [[ $exit_code -ne 0 && -n "$CURRENT_STEP" ]]; then error "Failed while: $CURRENT_STEP (see the output above). Fix the cause and re-run this installer." fi if [[ $exit_code -ne 0 && "$INSTALL_STARTED" == "1" ]]; then if [[ -n "$SOURCE_STAGING" ]]; then # The new tree was being built beside the old one and never swapped in. error "Update failed. The existing install at $INSTALL_DIR is unchanged." error "The unfinished new copy is in $SOURCE_STAGING; the next run replaces it." else error "Installation failed. Partial installation may remain at $INSTALL_DIR" error "To retry, run the installer again or remove the directory manually." fi if [[ -s "$LOG_FILE" ]]; then error "Step output was saved to $LOG_FILE" fi fi # A rename takes our serve mapping down in the question phase and the # after-the-build half puts it back; a failure in between leaves nothing # fronting Codeman, and `install.sh tailscale` is what restores it. if [[ $exit_code -ne 0 && "$TS_MAPPING_REMOVED_BY_RENAME" == "1" && -z "$TAILSCALE_SERVE_URL" ]]; then error "The Tailscale serve mapping was taken down for the rename and not re-added. Restore it with: bash $INSTALL_DIR/install.sh tailscale" fi } trap cleanup EXIT # ============================================================================ # System Detection # ============================================================================ detect_os() { local os os="$(uname -s)" case "$os" in Darwin) echo "macos" ;; Linux) echo "linux" ;; MINGW*|MSYS*|CYGWIN*) die "Windows is not supported directly. Please use WSL (Windows Subsystem for Linux)." ;; *) die "Unsupported operating system: $os" ;; esac } detect_arch() { local arch arch="$(uname -m)" case "$arch" in x86_64|amd64) echo "x64" ;; aarch64|arm64) echo "arm64" ;; armv7l) echo "armv7" ;; *) die "Unsupported architecture: $arch" ;; esac } detect_linux_distro() { if [[ ! -f /etc/os-release ]]; then # Fallback detection for older systems if [[ -f /etc/debian_version ]]; then echo "debian" elif [[ -f /etc/redhat-release ]]; then echo "fedora" elif [[ -f /etc/arch-release ]]; then echo "arch" elif [[ -f /etc/alpine-release ]]; then echo "alpine" else echo "unknown" fi return fi # Source os-release to get ID # shellcheck source=/dev/null source /etc/os-release case "${ID:-}" in debian|ubuntu|linuxmint|pop|elementary|zorin|kali|raspbian) echo "debian" ;; fedora|rhel|centos|rocky|alma|ol|amzn) echo "fedora" ;; arch|manjaro|endeavouros|garuda|artix) echo "arch" ;; opensuse*|sles|suse) echo "suse" ;; alpine) echo "alpine" ;; *) # Try ID_LIKE as fallback case "${ID_LIKE:-}" in *debian*|*ubuntu*) echo "debian" ;; *fedora*|*rhel*) echo "fedora" ;; *arch*) echo "arch" ;; *suse*) echo "suse" ;; *) echo "unknown" ;; esac ;; esac } # ============================================================================ # Prerequisite Checks # ============================================================================ check_curl_or_wget() { if command -v curl &>/dev/null; then DOWNLOADER="curl" return 0 elif command -v wget &>/dev/null; then DOWNLOADER="wget" return 0 fi return 1 } # DOWNLOADER is set by check_curl_or_wget, which only main() used to call: a # subcommand that downloads (`update` of an archive install, `cloudflared`, # `tailscale`) reached these two with it unset and died on `set -u` with the # message swallowed by the step log. So they resolve it themselves. require_downloader() { if [[ -n "${DOWNLOADER:-}" ]] || check_curl_or_wget; then return 0 fi die "curl or wget is required but neither is installed. Please install one first." } download() { local url="$1" local output="$2" require_downloader if [[ "$DOWNLOADER" == "curl" ]]; then curl -fsSL "$url" -o "$output" else wget -q "$url" -O "$output" fi } download_to_stdout() { local url="$1" require_downloader if [[ "$DOWNLOADER" == "curl" ]]; then curl -fsSL "$url" else wget -qO- "$url" fi } # ============================================================================ # Dependency Checks # ============================================================================ check_node() { if ! command -v node &>/dev/null; then return 1 fi local version version=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) if [[ -z "$version" ]] || [[ "$version" -lt "$MIN_NODE_VERSION" ]]; then return 1 fi return 0 } check_npm() { command -v npm &>/dev/null } # A git on PATH is not proof of a working git. On a Mac without the Xcode # Command Line Tools, /usr/bin/git is Apple's stub: running it opens the # "install the developer tools" dialog and exits non-zero, so that one is # judged by `xcode-select -p` and NEVER executed. Any other git is asked for # its version, which also rules out a broken one. check_git() { local git_bin git_bin=$(command -v git 2>/dev/null) || return 1 if [[ "$(uname -s)" == "Darwin" && "$git_bin" == "/usr/bin/git" ]]; then local dev_dir dev_dir=$(xcode-select -p 2>/dev/null) || return 1 if [[ -n "$dev_dir" && -x "$dev_dir/usr/bin/git" ]]; then return 0 fi return 1 fi "$git_bin" --version >/dev/null 2>&1 < /dev/null } check_tmux() { command -v tmux &>/dev/null } # node-pty ships prebuilt binaries for darwin and win32 ONLY, so on Linux it is # always compiled from source during `npm install`. Without a toolchain that # fails deep inside node-gyp with `not found: make`, which reads like an npm bug # rather than a missing system package (issue: fresh Ubuntu 24 server install). # So the toolchain is checked up front, exactly like git and tmux. # # Returns a human-readable list of what is missing, empty when all present. missing_build_tools() { local missing="" command -v make &>/dev/null || missing="make" if ! command -v c++ &>/dev/null && ! command -v g++ &>/dev/null && ! command -v clang++ &>/dev/null; then missing="${missing:+$missing, }g++" fi command -v python3 &>/dev/null || missing="${missing:+$missing, }python3" printf '%s' "$missing" } check_build_tools() { [[ -z "$(missing_build_tools)" ]] } # ============================================================================ # CLI Detection (generic, driven by the generated catalogue above) # ============================================================================ # # One implementation for every CLI, replacing nine near-identical # check_/get__path pairs plus their nine search-path arrays. Those had # to be extended by hand for each new CLI, and once were not: upstream b6d0f1fa # is "wire OMP into install.sh's CLI detection (it had none)", where a user with # only omp installed was told no AI CLI was found and offered Claude Code. # Adding an entry to stock.ts now wires detection, the install menu and the # closing reminder in one step. # # Probe order per CLI is UNCHANGED and pinned by # test/install-sh-detection-parity.test.ts: the process PATH first (each declared # binary name in turn), then each known install path, dir-major. # `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh` # (dancer's shell). The server-side resolver settles it by demanding the # harness's own help banner; detection here only feeds the "you have no AI CLI" # hint, so the same banner grep is enough — but unlike every sibling probe it # EXECUTES the candidate, so it must be bounded. /dev/null; then runner=(timeout 5); fi # ⚠️ bash 3.2 (stock macOS): expanding an EMPTY array under `set -u` is an unbound-variable # error, not a no-op — `${runner[@]}` alone abort­ed this whole probe with "runner[@]: # unbound variable" whenever `timeout` was absent (i.e. exactly the host this comment is # about). `${runner[@]+"${runner[@]}"}` expands to nothing when the array is empty and to # the quoted elements otherwise, which is safe under `set -u` in both bash 3.2 and 4+. ${runner[@]+"${runner[@]}"} "$1" --help /dev/null | grep -qi "DeepSeek Harness" } # Is "$2" really the CLI "$1" claims to be? # # Every CLI but DeepSeek is accepted on being executable, exactly as before. # DeepSeek stays a hand-written special case ON PURPOSE: the registry expresses # its identity check as `discovery.identity.regex`, a JavaScript regex, and # translating that into a `grep` pattern at install time is a transformation # nobody should be performing on a security-adjacent check. Instead # test/install-sh-invariants.test.ts pins the grep below against the registry's # `discovery.identity.regex`, so the two cannot drift apart: an upstream banner # change fails a test instead of silently mis-detecting here. _cli_candidate_ok() { case "$1" in deepseek) dsh_banner_probe "$2" ;; *) return 0 ;; esac } # Resolve every CLI in ONE pass, memoized. # # CLI_FOUND_PATH is parallel to CLI_IDS ('' when not found, and also '' for a # DISABLED entry — it is never probed at all, see below). CLI_FOUND_COUNT # counts only ENABLED entries that have a binary to look for, which is what the # "no AI CLI found" gate asks about — `shell` has no binary and must never make # that gate think an agent is installed. # # Memoizing the whole scan generalises the old resolve_dsh memo: the three call # sites together used to re-run every probe, and for dsh that meant executing a # possibly-foreign binary repeatedly. CLI_DETECT_DONE="" CLI_FOUND_PATH=() CLI_FOUND_COUNT=0 detect_all_clis() { [[ -n "$CLI_DETECT_DONE" ]] && return 0 CLI_DETECT_DONE=1 local i j found bin path bin_end path_end CLI_FOUND_COUNT=0 for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do found="" # A disabled entry is never even probed: every consumer already filters # on CLI_ENABLED before showing anything, so the command-v/stat calls # below would be pure waste — and, unlike filtering downstream, skipping # the probe here is what makes CLI_ENABLED mean "look for it" rather # than just "offer it once found". if [[ "${CLI_ENABLED[$i]}" != "1" ]]; then CLI_FOUND_PATH[$i]="" continue fi # 1. The process PATH, each declared binary name in turn. bin_end=$((${CLI_BIN_OFF[$i]} + ${CLI_BIN_LEN[$i]})) for ((j = ${CLI_BIN_OFF[$i]}; j < bin_end; j++)); do bin="${CLI_ALL_BINS[$j]}" if command -v "$bin" &>/dev/null; then path="$(command -v "$bin")" if _cli_candidate_ok "${CLI_IDS[$i]}" "$path"; then found="$path" break fi fi done # 2. The known install locations, dir-major. Note this still runs when a # PATH hit was REJECTED above — that is how a Debian `dsh` on PATH # does not hide a real harness in ~/.local/bin. if [[ -z "$found" ]]; then path_end=$((${CLI_PATH_OFF[$i]} + ${CLI_PATH_LEN[$i]})) for ((j = ${CLI_PATH_OFF[$i]}; j < path_end; j++)); do path="${CLI_ALL_PATHS[$j]}" if [[ -x "$path" ]] && _cli_candidate_ok "${CLI_IDS[$i]}" "$path"; then found="$path" break fi done fi CLI_FOUND_PATH[$i]="$found" if [[ -n "$found" ]] && [[ "${CLI_ENABLED[$i]}" == "1" ]] && [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]]; then CLI_FOUND_COUNT=$((CLI_FOUND_COUNT + 1)) fi done return 0 } # ---------------------------------------------------------------------------- # Catalogue helpers # ---------------------------------------------------------------------------- # Pick this platform's install commands out of the generated per-platform arrays. # # ⚠️ THE TRUST BOUNDARY LIVES HERE, and it is mechanical rather than a promise: # CLI_INSTALL_CMD_TRUSTED is written ONLY from CLI_CMD_LINUX/CLI_CMD_DARWIN, i.e. # only from the block generated into this file, and it is the sole array the # installer ever executes or displays — there is no second copy a network # refresh could rewrite. A command that runs therefore arrived in the same # file, over the same TLS fetch, in the same commit as the `curl | bash` line # that fetched this script. That is identical trust to the hardcoded vendor # one-liners this replaces, and it is why nothing fetched at install time is # ever executed. The server keeps its own, stricter rule unchanged: it never # executes an entry's install command at all (see CliDiscovery.install.command # in src/config/cli-registry/types.ts). CLI_INSTALL_CMD_TRUSTED=() CLI_PLATFORM_DONE="" cli_catalog_select_platform() { [[ -n "$CLI_PLATFORM_DONE" ]] && return 0 CLI_PLATFORM_DONE=1 # detect_os ONCE, not per entry: it forks a subshell, and on an unsupported # platform it also prints. Inside the loop that was ten forks and ten copies of # the same error, because a `die` inside $( ) can only exit the subshell. local i platform platform="$(detect_os)" for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do if [[ "$platform" == "macos" ]]; then CLI_INSTALL_CMD_TRUSTED[$i]="${CLI_CMD_DARWIN[$i]}" else CLI_INSTALL_CMD_TRUSTED[$i]="${CLI_CMD_LINUX[$i]}" fi done } # "Claude, OpenCode, Codex, ..." — the enabled, detectable CLIs, for prose. cli_catalog_names() { local i out="" for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue out="${out:+$out, }${CLI_LABELS[$i]}" done printf '%s' "$out" } # "claude, opencode, codex, ...": the ids --ai-cli takes. cli_catalog_ids() { local i out="" for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue out="${out:+$out, }${CLI_IDS[$i]}" done printf '%s' "$out" } # The "install one yourself" hints: every enabled CLI that is not installed, # showing the trusted install command. An entry with no install command gets # its docs URL instead of being silently omitted, which is what used to # happen to Gemini — it had a command in the registry and appeared in no list # in this script. DeepSeek is the one entry that deliberately HAS a command in # the registry but an empty one here: installing the launcher alone leaves # nothing that can drive a pane, so the generator withholds the command for # any launcherProfile entry (see installCommandFor in generate-cli-catalog.mts) # and this hint falls through to the docs URL instead — CLI_LAUNCHER_ONLY adds # one line explaining WHY it is a docs link and not a command, so a user who # follows that link straight to `npm install -g @deepseek-ai/dsh` (which the # docs page itself documents) does not land back in the same "installed but # cannot drive a pane" trap the menu exists to avoid. Data-driven, not an id # check: any future launcherProfile entry gets the same caveat for free. cli_catalog_print_install_hints() { detect_all_clis local i for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do [[ "${CLI_ENABLED[$i]}" == "1" ]] || continue [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue [[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue if [[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]]; then echo -e " ${CYAN}${CLI_INSTALL_CMD_TRUSTED[$i]}${NC} # ${CLI_LABELS[$i]}" elif [[ -n "${CLI_DOCS[$i]}" ]]; then echo -e " ${CLI_LABELS[$i]}: see ${CYAN}${CLI_DOCS[$i]}${NC}" if [[ "${CLI_LAUNCHER_ONLY[$i]}" == "1" ]]; then echo -e " (installs a launcher only: it still needs a terminal profile, and Codeman's Run menu can add one)" fi fi done } # Resolved at load, not lazily: every element of CLI_INSTALL_CMD_TRUSTED has to # exist before anything indexes it, or `set -u` aborts on an unset array element # the first time a hint is printed. cli_catalog_select_platform # cli_index : print the catalogue index of an enabled CLI that has a binary # to look for. Fails for anything else, `shell` included. cli_index() { local i for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do if [[ "${CLI_IDS[$i]}" == "$1" ]] && [[ "${CLI_ENABLED[$i]}" == "1" ]] && [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]]; then printf '%s' "$i" return 0 fi done return 1 } # cli_offerable : can this run install that entry by itself? 0 = yes, # 1 = no, 2 = no, only because its command needs curl and this host has wget. # One rule for the menu, for --ai-cli and for `preflight`, so what the desktop # app lists is exactly what the menu would. detect_all_clis must have run. # # ⚠️ The registry's install commands are a MIX: some call `curl` directly # (vendor one-liners), others are `npm install -g …`, which never needed curl # at all. A wget-only host used to lose the WHOLE menu over this, including # every npm entry: the two literals this replaced went through # download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1 # silently stopped defaulting to Claude Code as documented. So this is decided # per entry: only a command that actually starts with `curl ` is curl-dependent. # Rewriting curl to wget inside a string about to be executed is the wrong # instinct either way; the ones we can't run, we show as a hint. cli_offerable() { local i="$1" [[ "${CLI_ENABLED[$i]}" == "1" ]] || return 1 [[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || return 1 [[ -z "${CLI_FOUND_PATH[$i]}" ]] || return 1 [[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || return 1 if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then return 2 fi # Same reasoning for a Homebrew command: it cannot run where there is no # Homebrew, and the no-admin route never installs one. if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then return 1 fi return 0 } # Tell a running Codeman to look again: it remembers a missing CLI for up to 5 # minutes (cliResolveRetryDelayMs), and its Run button reads that memory. The name # is CLI_INSTALLED_MARKER in src/utils/cli-executable-resolver.ts. With no data # folder there is no Codeman to tell, which is why a failure here is not one. mark_cli_installed() { touch "$HOME/.codeman/cli-installed" 2>/dev/null || true return 0 } # run_offered_cli : run the trusted command of one entry and look again # at what is installed. What that means is the caller's to judge. run_offered_cli() { local idx="$1" info "Installing ${CLI_LABELS[$idx]}..." # /dev/null || true CLI_DETECT_DONE="" detect_all_clis if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}" mark_cli_installed else warn "${CLI_LABELS[$idx]} installation failed." fi return 0 } # install_offered_cli : the same during an install, where a chosen CLI # that leaves no CLI at all behind is fatal. install_offered_cli() { run_offered_cli "$1" if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then die "The selected AI CLI failed to install. Install one manually and re-run the installer." fi return 0 } # Install one AI CLI and nothing else (`install.sh agent `): how the desktop # app adds an agent once Codeman is here, also over a running server, which this # never touches. It judges the run itself, because install_offered_cli cannot: its # gate is fatal only when NO CLI is left, and here another one usually is, so a # failed install would report success, and its message is about re-running the # installer. A CLI that is here already is a success, and installs nothing. agent_subcommand() { if [[ -z "$AI_CLI_CHOICE" || "$AI_CLI_CHOICE" == "none" ]]; then die "Name the AI CLI to install: install.sh agent (one of: $(cli_catalog_ids))." fi check_curl_or_wget || die "curl or wget is required but neither is installed. Please install one first." detect_all_clis progress_phase agent local idx offerable=0 idx=$(cli_index "$AI_CLI_CHOICE") if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then success "${CLI_LABELS[$idx]} is already installed at ${CLI_FOUND_PATH[$idx]}" mark_cli_installed progress_phase done return 0 fi cli_offerable "$idx" || offerable=$? if [[ "$offerable" -ne 0 ]]; then die "${CLI_LABELS[$idx]} cannot be installed automatically here. See ${CLI_DOCS[$idx]:-its documentation}." fi # What this run sees is a login shell's PATH at best: a Node.js that only an # interactive profile loads (nvm in .zshrc) is not on it. if [[ "${CLI_INSTALL_CMD_TRUSTED[$idx]}" == npm\ * ]] && ! check_npm; then die "${CLI_LABELS[$idx]} installs with npm, and this run cannot find it. Run this in a terminal instead: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" fi run_offered_cli "$idx" if [[ -z "${CLI_FOUND_PATH[$idx]}" ]]; then die "${CLI_LABELS[$idx]} did not install. You can run its installer yourself: ${CLI_INSTALL_CMD_TRUSTED[$idx]}" fi progress_phase done return 0 } # Offer to install one AI CLI from the catalogue, or let the user skip. # # Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts # can drive the menu with a stubbed read_reply: the interactive path is the one # part of this script no static check reaches, and it is where choosing "s" (Skip) # once fell into the "failed to install" gate and aborted the whole installer. # That gate therefore lives INSIDE the install branch: skipping is a documented # choice that continues to the clone and build (sessions just need a CLI later), # while a chosen install that leaves nothing behind is still fatal. offer_ai_cli_install() { local i echo "" warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)." # --ai-cli is the consent the guard otherwise looks for at a terminal. if [[ -z "$AI_CLI_CHOICE" ]]; then headless_guard "install an AI CLI (curl | bash from its vendor)" fi echo "" # The menu is built from the catalogue: every enabled CLI that is not # installed and ships an install command we can run. It used to be a # fixed four-option prompt offering Claude Code and OpenCode only, so the # other seven were unreachable even though the registry knows how to # install five of them. # # ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED, # the only array the generated block above writes and the only one the # installer ever runs or displays — see cli_catalog_select_platform. local -a offer_idx=() local curl_only_skipped=0 offerable for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do offerable=0 cli_offerable "$i" || offerable=$? case "$offerable" in 0) offer_idx[${#offer_idx[@]}]=$i ;; 2) curl_only_skipped=$((curl_only_skipped + 1)) ;; esac done # The answer was given up front: never the menu, and never a different CLI # than the one named. A name this host cannot install (parse_flags already # refused a typo) warns and carries on, the same as Skip. if [[ -n "$AI_CLI_CHOICE" ]]; then local named="" entry if [[ "$AI_CLI_CHOICE" != "none" ]]; then named=$(cli_index "$AI_CLI_CHOICE") || named="" for entry in ${offer_idx[@]+"${offer_idx[@]}"}; do if [[ "$entry" == "$named" ]]; then install_offered_cli "$named" return 0 fi done warn "$AI_CLI_CHOICE cannot be installed automatically here. Codeman will run, but sessions need a CLI to drive." else warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." fi cli_catalog_print_install_hints return 0 fi if [[ "$curl_only_skipped" -gt 0 ]]; then warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)." fi if [[ ${#offer_idx[@]} -eq 0 ]]; then warn "No AI CLI can be installed automatically here. Codeman will run, but sessions need a CLI to drive." cli_catalog_print_install_hints else echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" local n=0 idx for idx in "${offer_idx[@]}"; do n=$((n + 1)) echo -e " ${CYAN}${n})${NC} ${CLI_LABELS[$idx]}" done echo -e " ${CYAN}s)${NC} Skip (I'll install one myself)" echo "" local cli_choice="" if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then # Explicit automation opt-in: default to the first OFFERED entry. # That is registry order, which is Claude Code (order 0), UNLESS # this is a wget-only host and Claude's curl one-liner was just # filtered out of offer_idx above — there, the first survivor is # whichever npm-based entry sorts earliest (Codex today), not # Claude. Printed either way so the choice is never silent. cli_choice="1" info "CODEMAN_NONINTERACTIVE=1: defaulting to ${CLI_LABELS[${offer_idx[0]}]}" else while true; do echo -en "${CYAN}Choose [1-${n}, or s to skip]:${NC} " >&2 read_reply cli_choice || { cli_choice="1"; break; } case "$cli_choice" in s|S) break ;; ''|*[!0-9]*) echo "Please enter a number between 1 and ${n}, or s." >&2 ;; *) if [[ "$cli_choice" -ge 1 ]] && [[ "$cli_choice" -le "$n" ]]; then break fi echo "Please enter a number between 1 and ${n}, or s." >&2 ;; esac done fi if [[ "$cli_choice" == "s" ]] || [[ "$cli_choice" == "S" ]]; then warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." cli_catalog_print_install_hints else install_offered_cli "${offer_idx[$((cli_choice - 1))]}" fi fi } check_cloudflared() { # Check ~/.local/bin first (matches tunnel-manager.ts resolution order) if [[ -x "$HOME/.local/bin/cloudflared" ]]; then return 0 fi if [[ -x "/usr/local/bin/cloudflared" ]]; then return 0 fi if command -v cloudflared &>/dev/null; then return 0 fi return 1 } get_cloudflared_path() { if [[ -x "$HOME/.local/bin/cloudflared" ]]; then echo "$HOME/.local/bin/cloudflared" return fi if [[ -x "/usr/local/bin/cloudflared" ]]; then echo "/usr/local/bin/cloudflared" return fi command -v cloudflared 2>/dev/null } # ============================================================================ # Dependency Installation # ============================================================================ # True when this account is in a group that normally carries administrator # rights: admin on macOS, sudo or wheel (or admin) on Linux. user_in_admin_group() { local group for group in $(id -Gn 2>/dev/null); do case "$group" in admin|sudo|wheel) return 0 ;; esac done return 1 } # Decide once whether this run may ask for administrator rights, and switch to # the no-admin route (below) when it may not. An explicit --no-admin or # CODEMAN_NO_ADMIN=0/1 wins. Otherwise root has them; a Mac account outside the # admin group does not (unless a Homebrew it can write to is already there); # on Linux an account has none without sudo, or when it is outside the admin # groups AND sudo will not run without a password. That last one is a judgment, # not a fact: an account sudoers lists by name, with a password, lands there # too, because nothing can read sudoers without a probe that reports to the # administrator. So it says "could not confirm", and every message that ends or # sums up the route names CODEMAN_NO_ADMIN=0 for it. # # Never decided by trying `sudo -v`: that asks for a password, and a wrong # answer from an account that may not use sudo is reported to the machine's # administrator. `sudo -n` never asks. resolve_admin_mode() { case "$NO_ADMIN" in 1) [[ -n "$NO_ADMIN_REASON" ]] || NO_ADMIN_REASON="--no-admin"; return 0 ;; 0) return 0 ;; esac NO_ADMIN="0" if [[ $EUID -eq 0 ]]; then return 0 fi if [[ "$(uname -s)" == "Darwin" ]]; then if user_in_admin_group; then return 0 fi if command -v brew &>/dev/null && [[ -w "$(brew --prefix 2>/dev/null)" ]]; then return 0 fi NO_ADMIN="1" NO_ADMIN_REASON="this account is not an administrator of this Mac" return 0 fi if ! command -v sudo &>/dev/null; then NO_ADMIN="1" NO_ADMIN_REASON="sudo is not installed" return 0 fi if user_in_admin_group || sudo -n true 2>/dev/null; then return 0 fi NO_ADMIN="1" NO_ADMIN_REASON="$NO_ADMIN_UNCONFIRMED" return 0 } # The way back to sudo, with a leading space so it can end a sentence, for a # route chosen because the rights could not be confirmed. Nothing for any other # reason: --no-admin asked for this route, and a sudo that is missing or refused # has no way back to offer. no_admin_override_hint() { if [[ "$NO_ADMIN_REASON" == "$NO_ADMIN_UNCONFIRMED" ]]; then printf '%s' " If this account can use sudo, run this again with CODEMAN_NO_ADMIN=0 set (for the install line: curl -fsSL https://getcodeman.com/install | CODEMAN_NO_ADMIN=0 bash)." fi return 0 } # The same hint on a line of its own, for a message that does not end the run. print_no_admin_override_hint() { local hint="" hint=$(no_admin_override_hint) if [[ -n "$hint" ]]; then echo -e " ${DIM}${hint# }${NC}" >&2 fi return 0 } ensure_sudo() { if [[ $EUID -eq 0 ]]; then return 0 fi if [[ "$NO_ADMIN" == "1" ]]; then die "This step needs administrator rights, and this run installs without them${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}.$(no_admin_override_hint)" fi if ! command -v sudo &>/dev/null; then die "sudo is required but not installed. Please install packages manually or run as root." fi # Validate sudo access # When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt if [[ -e /dev/tty ]]; then if ! sudo -v 2>/dev/null < /dev/tty; then die "Failed to obtain sudo privileges." fi else if ! sudo -v 2>/dev/null; then die "Failed to obtain sudo privileges. Try running the script directly instead of piping." fi fi } run_as_root() { if [[ $EUID -eq 0 ]]; then "$@" elif [[ "$NO_ADMIN" == "1" ]]; then # Every caller checks NO_ADMIN first; refusing here keeps a call site # that was missed from opening a sudo password prompt. error "Not running '$1' as root: this run installs without administrator rights." return 1 else sudo "$@" fi } ensure_homebrew() { if command -v brew &>/dev/null; then return 0 fi info "Installing Homebrew first..." # When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt if [[ -e /dev/tty ]]; then /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty else NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" fi # Add Homebrew to PATH for Apple Silicon if [[ -f /opt/homebrew/bin/brew ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [[ -f /usr/local/bin/brew ]]; then eval "$(/usr/local/bin/brew shellenv)" fi } install_node_macos() { info "Installing Node.js via Homebrew..." ensure_homebrew brew install node } install_node_debian() { info "Installing Node.js v$TARGET_NODE_VERSION via NodeSource..." ensure_sudo # Install prerequisites run_as_root apt-get update -qq run_as_root apt-get install -y -qq ca-certificates curl gnupg # Setup NodeSource repository (new method) run_as_root mkdir -p /etc/apt/keyrings # Remove old key if exists to avoid conflicts run_as_root rm -f /etc/apt/keyrings/nodesource.gpg download_to_stdout https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | run_as_root gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$TARGET_NODE_VERSION.x nodistro main" | run_as_root tee /etc/apt/sources.list.d/nodesource.list > /dev/null run_as_root apt-get update -qq run_as_root apt-get install -y -qq nodejs } install_node_fedora() { info "Installing Node.js v$TARGET_NODE_VERSION via NodeSource..." ensure_sudo # Import NodeSource GPG key run_as_root rpm --import https://rpm.nodesource.com/gpgkey/nodesource-repo.gpg.key # Create repo file (replaces deprecated setup_XX.x bash script) cat << REPO_EOF | run_as_root tee /etc/yum.repos.d/nodesource.repo > /dev/null [nodesource] name=Node.js Packages for Linux RPM - nodesource baseurl=https://rpm.nodesource.com/pub_${TARGET_NODE_VERSION}.x/nodistro/rpm/\$basearch gpgcheck=1 gpgkey=https://rpm.nodesource.com/gpgkey/nodesource-repo.gpg.key enabled=1 REPO_EOF # Use dnf if available (RHEL 8+, Fedora, AL2023), fall back to yum (RHEL 7, AL2) if command -v dnf &>/dev/null; then run_as_root dnf install -y nodejs else run_as_root yum install -y nodejs fi } install_node_arch() { info "Installing Node.js via pacman..." ensure_sudo run_as_root pacman -Sy --noconfirm nodejs npm # Verify version is sufficient local version version=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) if [[ "$version" -lt "$MIN_NODE_VERSION" ]]; then warn "Arch package nodejs is v$version, which is older than required v$MIN_NODE_VERSION" warn "Consider using nvm or the nodejs-lts-* package instead" fi } install_node_alpine() { info "Installing Node.js via apk..." ensure_sudo run_as_root apk add --no-cache nodejs npm # Verify version local version version=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) if [[ "$version" -lt "$MIN_NODE_VERSION" ]]; then warn "Alpine package nodejs is v$version, which is older than required v$MIN_NODE_VERSION" warn "Consider using a newer Alpine version or building from source" fi } install_node_suse() { info "Installing Node.js v$TARGET_NODE_VERSION via NodeSource..." ensure_sudo # Import NodeSource GPG key run_as_root rpm --import https://rpm.nodesource.com/gpgkey/nodesource-repo.gpg.key # Create repo file (replaces deprecated setup_XX.x bash script) cat << REPO_EOF | run_as_root tee /etc/zypp/repos.d/nodesource.repo > /dev/null [nodesource] name=Node.js Packages for Linux RPM - nodesource baseurl=https://rpm.nodesource.com/pub_${TARGET_NODE_VERSION}.x/nodistro/rpm/\$basearch gpgcheck=1 gpgkey=https://rpm.nodesource.com/gpgkey/nodesource-repo.gpg.key enabled=1 REPO_EOF run_as_root zypper install -y nodejs } install_tmux_macos() { info "Installing tmux via Homebrew..." ensure_homebrew brew install tmux } install_tmux_debian() { info "Installing tmux via apt..." ensure_sudo run_as_root apt-get update -qq run_as_root apt-get install -y -qq tmux } install_tmux_fedora() { info "Installing tmux..." ensure_sudo if command -v dnf &>/dev/null; then run_as_root dnf install -y tmux else run_as_root yum install -y tmux fi } install_tmux_arch() { info "Installing tmux via pacman..." ensure_sudo run_as_root pacman -Sy --noconfirm tmux } install_tmux_alpine() { info "Installing tmux via apk..." ensure_sudo run_as_root apk add --no-cache tmux } install_tmux_suse() { info "Installing tmux via zypper..." ensure_sudo run_as_root zypper install -y tmux } install_git_macos() { info "Installing Git via Homebrew..." ensure_homebrew brew install git } install_git_debian() { info "Installing Git via apt..." ensure_sudo run_as_root apt-get update -qq run_as_root apt-get install -y -qq git } install_git_fedora() { info "Installing Git..." ensure_sudo if command -v dnf &>/dev/null; then run_as_root dnf install -y git else run_as_root yum install -y git fi } install_git_arch() { info "Installing Git via pacman..." ensure_sudo run_as_root pacman -Sy --noconfirm git } install_git_alpine() { info "Installing Git via apk..." ensure_sudo run_as_root apk add --no-cache git } install_git_suse() { info "Installing Git via zypper..." ensure_sudo run_as_root zypper install -y git } # Build toolchain for node-pty's source compile (see missing_build_tools). install_buildtools_debian() { info "Installing build tools via apt (build-essential, python3)..." ensure_sudo run_as_root apt-get update -qq run_as_root apt-get install -y -qq build-essential python3 } install_buildtools_fedora() { info "Installing build tools (gcc, gcc-c++, make, python3)..." ensure_sudo if command -v dnf &>/dev/null; then run_as_root dnf install -y gcc gcc-c++ make python3 else run_as_root yum install -y gcc gcc-c++ make python3 fi } install_buildtools_arch() { info "Installing build tools via pacman (base-devel, python)..." ensure_sudo run_as_root pacman -Sy --noconfirm base-devel python } install_buildtools_alpine() { info "Installing build tools via apk (build-base, python3)..." ensure_sudo run_as_root apk add --no-cache build-base python3 } install_buildtools_suse() { info "Installing build tools via zypper..." ensure_sudo run_as_root zypper install -y gcc gcc-c++ make python3 } install_buildtools_macos() { # macOS normally never gets here: node-pty ships darwin prebuilds. Only a # forced source build needs a compiler, and Xcode CLT is its only supplier. info "Requesting Xcode Command Line Tools..." xcode-select --install 2>/dev/null || true die "Finish the Xcode Command Line Tools install in the dialog, then re-run this installer." } install_cloudflared_macos() { info "Installing cloudflared via Homebrew..." ensure_homebrew brew install cloudflared } install_cloudflared_debian() { info "Installing cloudflared..." ensure_sudo local arch arch="$(dpkg --print-architecture 2>/dev/null || echo "amd64")" local tmp tmp="$(mktemp)" download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$arch.deb" "$tmp" run_as_root dpkg -i "$tmp" rm -f "$tmp" } install_cloudflared_fedora() { info "Installing cloudflared..." ensure_sudo local arch arch="$(uname -m)" local rpm_arch="$arch" [[ "$arch" == "x86_64" ]] && rpm_arch="x86_64" [[ "$arch" == "aarch64" ]] && rpm_arch="aarch64" local tmp tmp="$(mktemp)" download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$rpm_arch.rpm" "$tmp" run_as_root rpm -i "$tmp" || run_as_root rpm -U "$tmp" rm -f "$tmp" } install_cloudflared_arch() { info "Installing cloudflared binary..." local arch arch="$(uname -m)" local cf_arch="amd64" [[ "$arch" == "aarch64" ]] && cf_arch="arm64" [[ "$arch" == "armv7l" ]] && cf_arch="arm" ensure_sudo local tmp tmp="$(mktemp)" download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$cf_arch" "$tmp" run_as_root mv "$tmp" /usr/local/bin/cloudflared run_as_root chmod +x /usr/local/bin/cloudflared } install_cloudflared_alpine() { info "Installing cloudflared binary..." local arch arch="$(uname -m)" local cf_arch="amd64" [[ "$arch" == "aarch64" ]] && cf_arch="arm64" [[ "$arch" == "armv7l" ]] && cf_arch="arm" ensure_sudo local tmp tmp="$(mktemp)" download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$cf_arch" "$tmp" run_as_root mv "$tmp" /usr/local/bin/cloudflared run_as_root chmod +x /usr/local/bin/cloudflared } install_cloudflared_suse() { info "Installing cloudflared..." ensure_sudo local arch arch="$(uname -m)" local rpm_arch="$arch" local tmp tmp="$(mktemp)" download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$rpm_arch.rpm" "$tmp" run_as_root rpm -i "$tmp" || run_as_root rpm -U "$tmp" rm -f "$tmp" } # ============================================================================ # No-admin route: everything into the home folder, no administrator rights # ============================================================================ # # What Codeman itself writes was always under $HOME: the app, the `codeman` # symlink, the user-level service. Administrator rights were only ever spent on # what it needs first (system packages for Node.js, tmux and git; Homebrew on a # Mac). This route supplies those without them: # # Node.js the official tarball from nodejs.org, checked against the # SHASUMS256.txt published beside it, unpacked to $RUNTIME_DIR/node # tmux the tmux project's own build, checked against the digest pinned in # tmux_build_sha256, in $RUNTIME_DIR/bin (linked from ~/.local/bin) # git never installed: where no git works, the source comes as an # archive (fetch_source_archive) and such an install updates by # re-running this installer # # Two things it cannot supply, both checked BEFORE anything is downloaded # (no_admin_blocker): a C/C++ toolchain on Linux, where node-pty compiles from # source, and a tmux for a Mac older than macOS 15, the deployment target of # the official build. # # Rule for every function between this header and "End of the no-admin route": # none of them runs sudo, ensure_sudo, run_as_root, sudo_session_*, Homebrew or # `xcode-select --install` (pinned by test/install-sh-invariants.test.ts). # sha256_file : the file's SHA-256 in lowercase hex. Fails when the # machine has no tool for it; a download is never unpacked unverified. sha256_file() { local file="$1" out="" if command -v sha256sum &>/dev/null; then out=$(sha256sum "$file" 2>/dev/null) || return 1 elif command -v shasum &>/dev/null; then out=$(shasum -a 256 "$file" 2>/dev/null) || return 1 elif command -v openssl &>/dev/null; then out=$(openssl dgst -sha256 -r "$file" 2>/dev/null) || return 1 else return 1 fi printf '%s' "${out%% *}" | tr 'A-F' 'a-f' } # verify_sha256 verify_sha256() { local file="$1" want="$2" got="" [[ -n "$want" ]] || return 1 got=$(sha256_file "$file") || return 1 [[ "$got" == "$want" ]] } # The platform part of a Node.js tarball name; fails where nodejs.org ships # none that this route supports. userspace_node_platform() { case "$1-$2" in linux-x64) printf 'linux-x64' ;; linux-arm64) printf 'linux-arm64' ;; macos-x64) printf 'darwin-x64' ;; macos-arm64) printf 'darwin-arm64' ;; *) return 1 ;; esac } # The platform part of a tmux-builds asset name. userspace_tmux_platform() { case "$1-$2" in linux-x64) printf 'linux-x86_64' ;; linux-arm64) printf 'linux-arm64' ;; macos-x64) printf 'macos-x86_64' ;; macos-arm64) printf 'macos-arm64' ;; *) return 1 ;; esac } # SHA-256 of tmux-$TMUX_BUILD_VERSION-.tar.gz, as published on the # release page (the `digest` field of the GitHub release API) and re-hashed # from a fresh download when this version was pinned. tmux_build_sha256() { case "$1" in linux-x86_64) printf 'cc56bd1cc873eb6089c615f0496b072385bda8a6d944069f38564a5d49c128aa' ;; linux-arm64) printf '29dcb978da2a4b0cf6790f9e004865dac239a3ba03bbf776dddacce23d03831b' ;; macos-arm64) printf '0a763dd0380aa980d239509654da1bc7455843706a3c050f6709c8cd2e13d12d' ;; macos-x86_64) printf '1c21f9ade964e4a539be05ffe3a4e95854ab76a0a9c4c05d0ffbe7fc6c4d783f' ;; *) return 1 ;; esac } # Alpine and friends: the official Node.js tarballs are linked against glibc. is_musl_libc() { [[ "$(uname -s)" == "Linux" ]] || return 1 if ls /lib/ld-musl-* >/dev/null 2>&1; then return 0 fi command -v ldd &>/dev/null || return 1 case "$(ldd --version 2>&1 || true)" in *musl*) return 0 ;; esac return 1 } macos_major_version() { local version version=$(sw_vers -productVersion 2>/dev/null) || return 1 printf '%s' "${version%%.*}" } # pick_node_tarball : reads a SHASUMS256.txt on stdin and prints # " " for that platform's .tar.gz. Pure (the test suite # feeds it a fixture), and it reads all of stdin so the writer never sees a # closed pipe under `set -o pipefail`. pick_node_tarball() { local platform="$1" sha="" name="" found="" hex='^[0-9a-f]{64}$' while read -r sha name; do [[ -z "$found" ]] || continue case "$name" in node-v*-"$platform".tar.gz) if [[ "$sha" =~ $hex ]]; then found="$sha $name" fi ;; esac done [[ -n "$found" ]] || return 1 printf '%s\n' "$found" } # ensure_on_path

: first on PATH for the rest of this run, and in the # shell profile for later ones. add_to_path alone is not enough: it returns # before exporting when the profile already carries the line (a re-run from a # shell that has not loaded its profile). ensure_on_path() { local dir="$1" if [[ ":$PATH:" == *":$dir:"* ]]; then return 0 fi add_to_path "$dir" if [[ ":$PATH:" != *":$dir:"* ]]; then export PATH="$dir:$PATH" fi return 0 } # link_into_local_bin : make ~/.local/bin/ point at # something this route unpacked, so it is found wherever `codeman` itself is. # That matters for node: the `codeman` launcher starts with # `#!/usr/bin/env node`, and a non-interactive shell (ssh host 'codeman ...', # cron) never reads the PATH line this installer adds to an interactive # profile. Returns 1, changing nothing, when a file or link that is not ours # is already there. link_into_local_bin() { local target="$1" link="$HOME/.local/bin/$2" if [[ -e "$link" || -L "$link" ]] && [[ "$(readlink "$link" 2>/dev/null)" != "$RUNTIME_DIR/"* ]]; then return 1 fi mkdir -p "$HOME/.local/bin" ln -sf "$target" "$link" } # A re-run often starts in a shell that has not loaded the profile an earlier # no-admin run wrote (a fresh `curl | bash`, a non-login shell), so what that # run unpacked would look missing and be downloaded again. Put it back on PATH # for this run. Runs for every subcommand: `update` needs node and npm too. adopt_userspace_runtime() { if [[ -x "$RUNTIME_DIR/node/bin/node" && ":$PATH:" != *":$RUNTIME_DIR/node/bin:"* ]]; then export PATH="$RUNTIME_DIR/node/bin:$PATH" fi if [[ -x "$RUNTIME_DIR/bin/tmux" ]] && ! command -v tmux &>/dev/null; then export PATH="$RUNTIME_DIR/bin:$PATH" fi return 0 } # no_admin_blocker : prints why # this route cannot finish on this machine, or nothing when it can. Checks # only, so it runs before the first download: nobody should wait for 50 MB of # Node.js to then learn that a compiler is missing. no_admin_blocker() { local os="$1" arch="$2" need_node="$3" need_tmux="$4" if [[ "$os" == "linux" ]] && ! check_build_tools; then printf '%s' "Codeman's terminal module (node-pty) is compiled during the install on Linux, which needs make, a C++ compiler and python3. Missing here: $(missing_build_tools). Installing them takes an administrator (Debian/Ubuntu: apt-get install -y build-essential python3; Fedora/RHEL: dnf install -y gcc gcc-c++ make python3). Once they are there, re-run this installer: the rest needs no administrator rights." return 0 fi if [[ "$need_node" == "1" ]]; then if ! userspace_node_platform "$os" "$arch" >/dev/null; then printf '%s' "nodejs.org has no Node.js download this installer can use on $os $arch. Install Node.js $TARGET_NODE_VERSION yourself, then re-run this installer." return 0 fi if is_musl_libc; then printf '%s' "The official Node.js downloads need glibc, and this system uses musl (Alpine). Install Node.js $TARGET_NODE_VERSION yourself (an administrator can run: apk add nodejs npm), then re-run this installer." return 0 fi fi if [[ "$need_tmux" == "1" ]]; then if ! userspace_tmux_platform "$os" "$arch" >/dev/null; then printf '%s' "The tmux project publishes no build for $os $arch. Ask an administrator to install tmux, then re-run this installer." return 0 fi if [[ "$os" == "macos" ]]; then local major="" digits='^[0-9]+$' major=$(macos_major_version) || major="" if ! [[ "$major" =~ $digits ]] || [[ "$major" -lt 15 ]]; then printf '%s' "The tmux project's build needs macOS 15 or newer, and this Mac runs ${major:-an unknown version}. Ask an administrator to install tmux once (brew install tmux), then re-run this installer: the rest needs no administrator rights." return 0 fi fi fi if [[ "$need_node" == "1" || "$need_tmux" == "1" ]]; then if ! command -v tar &>/dev/null; then printf '%s' "tar is needed to unpack the downloads and is not installed." return 0 fi if ! command -v sha256sum &>/dev/null && ! command -v shasum &>/dev/null && ! command -v openssl &>/dev/null; then printf '%s' "No SHA-256 tool (sha256sum, shasum or openssl) is installed, so the downloads could not be verified." return 0 fi fi return 0 } # Node.js $TARGET_NODE_VERSION (latest patch release) into $RUNTIME_DIR/node. # Unpacked beside the old copy and proven to run before it replaces it. install_node_userspace() { local os="$1" arch="$2" platform="" sums="" line="" sha="" name="" version="" tmp="" local dest="$RUNTIME_DIR/node" base="https://nodejs.org/dist" platform=$(userspace_node_platform "$os" "$arch") || die "No Node.js download for $os $arch." info "Looking up the latest Node.js $TARGET_NODE_VERSION..." sums=$(download_to_stdout "$base/latest-v${TARGET_NODE_VERSION}.x/SHASUMS256.txt" < /dev/null) \ || die "Could not reach nodejs.org. Check the network (or a proxy: https_proxy) and re-run." line=$(printf '%s\n' "$sums" | pick_node_tarball "$platform") \ || die "nodejs.org lists no Node.js $TARGET_NODE_VERSION build for $platform." sha="${line%% *}" name="${line#* }" version="${name#node-}" version="${version%%-*}" tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-node.XXXXXX") || die "Could not create a temporary folder." if ! run_step "Downloading Node.js $version" download "$base/$version/$name" "$tmp/$name"; then rm -rf "$tmp" exit 1 fi if ! verify_sha256 "$tmp/$name" "$sha"; then rm -rf "$tmp" die "The Node.js download does not match the checksum nodejs.org publishes for it. Nothing was installed; re-run to try again." fi mkdir -p "$RUNTIME_DIR" rm -rf "$dest.new" mkdir -p "$dest.new" if ! tar -xzf "$tmp/$name" -C "$dest.new" --strip-components=1 < /dev/null; then rm -rf "$tmp" "$dest.new" die "Could not unpack the Node.js download." fi rm -rf "$tmp" if ! "$dest.new/bin/node" --version >/dev/null 2>&1 < /dev/null; then rm -rf "$dest.new" die "The downloaded Node.js does not run here (on Linux the official builds need glibc 2.28 or newer). Install Node.js $TARGET_NODE_VERSION yourself, then re-run this installer." fi rm -rf "$dest.old" if [[ -e "$dest" ]]; then mv "$dest" "$dest.old" fi mv "$dest.new" "$dest" rm -rf "$dest.old" # On PATH as a folder (what `npm install -g` puts beside it is found too), # and linked from ~/.local/bin for shells that never read the profile. ensure_on_path "$dest/bin" local tool for tool in node npm npx; do link_into_local_bin "$dest/bin/$tool" "$tool" || true done # An older Node.js earlier on PATH would still win for this run. if [[ "$(command -v node 2>/dev/null)" != "$dest/bin/node" ]]; then export PATH="$dest/bin:$PATH" fi hash -r 2>/dev/null || true success "Node.js $version installed in $dest" return 0 } # tmux $TMUX_BUILD_VERSION into $RUNTIME_DIR/bin, linked from ~/.local/bin # (already on the PATH of the service this installer writes). install_tmux_userspace() { local os="$1" arch="$2" platform="" sha="" name="" tmp="" local bin_dir="$RUNTIME_DIR/bin" link_dir="$HOME/.local/bin" platform=$(userspace_tmux_platform "$os" "$arch") || die "No tmux build for $os $arch." sha=$(tmux_build_sha256 "$platform") || die "No pinned checksum for the $platform tmux build." name="tmux-$TMUX_BUILD_VERSION-$platform.tar.gz" # Staged in the runtime folder, as Node.js is: /tmp is often mounted noexec # on the shared hosts this route is for, and the proof run below would then # call a working build one that does not run. mkdir -p "$bin_dir" tmp=$(mktemp -d "$RUNTIME_DIR/codeman-tmux.XXXXXX") || die "Could not create a temporary folder." if ! run_step "Downloading tmux $TMUX_BUILD_VERSION" download \ "https://github.com/tmux/tmux-builds/releases/download/v$TMUX_BUILD_VERSION/$name" "$tmp/$name"; then rm -rf "$tmp" exit 1 fi if ! verify_sha256 "$tmp/$name" "$sha"; then rm -rf "$tmp" die "The tmux download does not match the checksum pinned in this installer. Nothing was installed; re-run to try again." fi if ! tar -xzf "$tmp/$name" -C "$tmp" < /dev/null || [[ ! -f "$tmp/tmux" ]]; then rm -rf "$tmp" die "Could not unpack the tmux download." fi chmod 755 "$tmp/tmux" if ! "$tmp/tmux" -V >/dev/null 2>&1 < /dev/null; then rm -rf "$tmp" die "The downloaded tmux does not run on this system. Ask an administrator to install tmux, then re-run this installer." fi mv -f "$tmp/tmux" "$bin_dir/tmux" rm -rf "$tmp" # A tmux someone else put at ~/.local/bin/tmux is not ours to replace: put # the runtime folder itself on PATH in that case. if link_into_local_bin "$bin_dir/tmux" tmux; then ensure_on_path "$link_dir" else ensure_on_path "$bin_dir" fi hash -r 2>/dev/null || true success "tmux $TMUX_BUILD_VERSION installed in $bin_dir" return 0 } # cloudflared into ~/.local/bin, where the server looks for it first. Cloudflare # publishes no checksum file; this takes the release asset over TLS, exactly as # the system routes above take the same release's packages and binaries. install_cloudflared_userspace() { local os="$1" arch="$2" asset="" tmp="" src="" dest="$HOME/.local/bin/cloudflared" case "$os-$arch" in linux-x64) asset="cloudflared-linux-amd64" ;; linux-arm64) asset="cloudflared-linux-arm64" ;; linux-armv7) asset="cloudflared-linux-arm" ;; macos-x64) asset="cloudflared-darwin-amd64.tgz" ;; macos-arm64) asset="cloudflared-darwin-arm64.tgz" ;; *) die "Cloudflare publishes no cloudflared build for $os $arch." ;; esac # Staged in the home folder for the reason in install_tmux_userspace. mkdir -p "$HOME/.local/bin" tmp=$(mktemp -d "$HOME/.local/codeman-cloudflared.XXXXXX") || die "Could not create a temporary folder." if ! run_step "Downloading cloudflared" download \ "https://github.com/cloudflare/cloudflared/releases/latest/download/$asset" "$tmp/$asset"; then rm -rf "$tmp" exit 1 fi src="$tmp/$asset" if [[ "$asset" == *.tgz ]]; then if ! tar -xzf "$tmp/$asset" -C "$tmp" < /dev/null || [[ ! -f "$tmp/cloudflared" ]]; then rm -rf "$tmp" die "Could not unpack the cloudflared download." fi src="$tmp/cloudflared" fi chmod 755 "$src" if ! "$src" --version >/dev/null 2>&1 < /dev/null; then rm -rf "$tmp" die "The downloaded cloudflared does not run on this system." fi mv -f "$src" "$dest" rm -rf "$tmp" return 0 } # The no-admin half of ask_dependencies: say what stands in the way before # downloading anything, ask once, fetch what is missing, then prove it runs. # $2 = "consented" when the user already said yes to installing the missing # pieces (the fallback after a refused sudo password), so they are not asked # the same thing twice. ask_dependencies_no_admin() { local os="$1" consented="${2:-}" arch="" blocker="" what="" local need_node="0" need_tmux="0" need_git="0" arch=$(detect_arch) check_node || need_node="1" check_tmux || need_tmux="1" check_git || need_git="1" blocker=$(no_admin_blocker "$os" "$arch" "$need_node" "$need_tmux") if [[ -n "$blocker" ]]; then die "$blocker$(no_admin_override_hint)" fi if [[ "$need_git" == "1" ]]; then if [[ -d "$INSTALL_DIR/.git" ]]; then die "$INSTALL_DIR is a git checkout, and no working git is available here to update it. Ask an administrator for git, or remove that folder for a fresh install, then re-run." fi if ! source_tarball_url >/dev/null; then die "No working git is available, and $REPO_URL is not a GitHub repository this installer can fetch as an archive. Set CODEMAN_TARBALL_URL to a .tar.gz of the source, then re-run." fi fi info "Installing without administrator rights${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}." if [[ "$need_git" == "1" ]]; then info "No working git here: Codeman is installed from a source archive, and updates by re-running this installer." fi if [[ "$need_node" == "1" ]]; then what="Node.js $TARGET_NODE_VERSION (from nodejs.org)" fi if [[ "$need_tmux" == "1" ]]; then what="${what:+$what and }tmux $TMUX_BUILD_VERSION (from github.com/tmux/tmux-builds)" fi if [[ -n "$what" ]]; then headless_guard "download $what into $RUNTIME_DIR" if [[ "$consented" != "consented" ]] && ! prompt_yes_no "Download $what into your home folder ($RUNTIME_DIR)?" "y"; then die "Codeman needs $what. Install them and re-run this installer.$(no_admin_override_hint)" fi if [[ "$need_node" == "1" ]]; then CURRENT_STEP="installing Node.js $TARGET_NODE_VERSION into $RUNTIME_DIR" install_node_userspace "$os" "$arch" fi if [[ "$need_tmux" == "1" ]]; then CURRENT_STEP="installing tmux into $RUNTIME_DIR" install_tmux_userspace "$os" "$arch" fi CURRENT_STEP="" hash -r 2>/dev/null || true fi # Prove it, never assume it. git is deliberately absent from this list. check_node || die "Node.js $MIN_NODE_VERSION+ is still missing after the install." check_npm || die "npm is not available next to that Node.js." check_tmux || die "tmux is still missing after the install (sessions live inside tmux)." if [[ -n "$what" ]]; then success "Dependencies installed without administrator rights" fi return 0 } # Where the source archive comes from when there is no git to clone with: # CODEMAN_TARBALL_URL, else GitHub's archive of the configured repository at # the configured branch or tag. Fails for a repository that is not on GitHub. source_tarball_url() { if [[ -n "${CODEMAN_TARBALL_URL:-}" ]]; then printf '%s' "$CODEMAN_TARBALL_URL" return 0 fi local base="${REPO_URL%/}" base="${base%.git}" case "$base" in https://github.com/*/*) printf '%s/archive/%s.tar.gz' "$base" "$BRANCH" ;; *) return 1 ;; esac } # fetch_source_archive : unpack the source into , which must be # missing or empty. The marker goes in FIRST, so a run that dies half way # through leaves something the next run recognises as its own and replaces. fetch_source_archive() { local dest="$1" url="" tmp="" url=$(source_tarball_url) || die "No source archive is configured for $REPO_URL (set CODEMAN_TARBALL_URL)." tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-source.XXXXXX") || die "Could not create a temporary folder." if ! run_step "Downloading Codeman" download "$url" "$tmp/source.tar.gz"; then rm -rf "$tmp" exit 1 fi mkdir -p "$dest" printf 'archive\n' > "$dest/$SOURCE_MARKER" if ! tar -xzf "$tmp/source.tar.gz" -C "$dest" --strip-components=1 < /dev/null; then rm -rf "$tmp" die "Could not unpack the Codeman source archive from $url." fi rm -rf "$tmp" if [[ ! -f "$dest/package.json" ]]; then die "The archive at $url does not look like the Codeman source (no package.json at its top level)." fi return 0 } # ---- End of the no-admin route --------------------------------------------- # ============================================================================ # Interactive Prompts # ============================================================================ # `curl | bash` leaves stdin attached to the pipe, so a plain `read` never sees # the keyboard even though the user is sitting at a terminal. These helpers # prompt via /dev/tty whenever a real terminal is available, and only fall back # to defaults when there is genuinely none (CI, truly headless pipes). has_tty() { [[ -t 0 ]] && return 0 { : < /dev/tty; } 2>/dev/null } read_reply() { # read_reply : read one line from the user's real terminal if [[ -t 0 ]]; then read -r "$1" else read -r "$1" < /dev/tty fi } read_secret() { # read_secret : like read_reply but without echoing (passwords) if [[ -t 0 ]]; then read -rs "$1" else read -rs "$1" < /dev/tty fi echo "" >&2 } # headless_guard : refuse consequential system changes (sudo package # installs, third-party curl | bash installers) when nobody can consent, i.e. # no terminal AND no explicit CODEMAN_NONINTERACTIVE=1 opt-in. Interactive # runs fall through to their normal prompt; opted-in automation proceeds with # the prompt defaults as before. headless_guard() { local action="$1" if [[ "$NONINTERACTIVE" == "1" ]] || has_tty; then return 0 fi error "No interactive terminal, but the installer would need to: $action." error "Re-run from a terminal to be prompted, or set CODEMAN_NONINTERACTIVE=1 to approve such steps in automation." exit 1 } prompt_yes_no() { local prompt="$1" local default="${2:-y}" # Non-interactive, or --yes: take the default. if [[ "$NONINTERACTIVE" == "1" ]] || [[ "$ASSUME_YES" == "1" ]] || ! has_tty; then [[ "$default" == "y" ]] return fi local yn_hint if [[ "$default" == "y" ]]; then yn_hint="[Y/n]" else yn_hint="[y/N]" fi while true; do echo -en "${CYAN}$prompt${NC} $yn_hint " >&2 read_reply answer || answer="$default" answer="${answer:-$default}" case "$answer" in [Yy]|[Yy][Ee][Ss]) return 0 ;; [Nn]|[Nn][Oo]) return 1 ;; *) echo "Please answer yes or no." >&2 ;; esac done } # Some steps need root (system packages, the Tailscale installer, `tailscale # up`, the operator grant). Ask for the password ONCE, up front, and keep the # sudo timestamp warm in the background for the rest of the run, instead of a # password prompt per step scattered around a multi-minute build. # # sudo_session_try returns 1 when the password is refused, so a caller can fall # back to the no-admin route; sudo_session_start ends the run instead. sudo_session_try() { [[ $EUID -eq 0 ]] && return 0 [[ -n "$SUDO_KEEPALIVE_PID" ]] && return 0 [[ "$NO_ADMIN" == "1" ]] && return 0 command -v sudo &>/dev/null || return 0 if ! sudo -n true 2>/dev/null; then echo "" >&2 info "Some steps need administrator rights. You will be asked for your password once." if [[ -e /dev/tty ]]; then sudo -v < /dev/tty || return 1 else sudo -v || return 1 fi fi # Refreshes the timestamp while this script lives; ends on its own once the # installer is gone or the credential lapses. ( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || exit 0 sleep 50 done ) & SUDO_KEEPALIVE_PID=$! return 0 } sudo_session_start() { if sudo_session_try; then return 0 fi if [[ -e /dev/tty ]]; then die "Failed to obtain sudo privileges." fi die "Failed to obtain sudo privileges. Try running the script directly instead of piping." } # Open a URL in the user's browser when there is one to open it in (macOS, a # desktop session, WSL). Silent no-op on a headless box: the URL is printed # anyway. open_in_browser() { local url="$1" if [[ "$(uname -s)" == "Darwin" ]]; then open "$url" >/dev/null 2>&1 || true elif command -v wslview &>/dev/null; then wslview "$url" >/dev/null 2>&1 || true elif [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]] && command -v xdg-open &>/dev/null; then xdg-open "$url" >/dev/null 2>&1 & fi return 0 } fmt_elapsed() { local s="$1" if [[ "$s" -ge 60 ]]; then printf '%dm %02ds' $((s / 60)) $((s % 60)) else printf '%ds' "$s" fi } # run_step