#!/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) # --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) # # 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 set -euo pipefail # ============================================================================ # Configuration # ============================================================================ INSTALL_DIR="${CODEMAN_INSTALL_DIR:-$HOME/.codeman/app}" 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}" # 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" 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') CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP') CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1) CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 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') 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') 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') CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp') CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8) CLI_BIN_LEN=(1 0 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") CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41) CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6) # <<< 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 } 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 error "Installation failed. Partial installation may remain at $INSTALL_DIR" error "To retry, run the installer again or remove the directory manually." 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 } download() { local url="$1" local output="$2" if [[ "$DOWNLOADER" == "curl" ]]; then curl -fsSL "$url" -o "$output" else wget -q "$url" -O "$output" fi } download_to_stdout() { local url="$1" 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 } check_git() { command -v git &>/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" } # 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 # 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)." headless_guard "install an AI CLI (curl | bash from its vendor)" 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. # # ⚠️ 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. Filter per # entry instead: only a command that actually starts with `curl ` is # curl-dependent, so only THOSE are held back on a wget-only host. # 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. local -a offer_idx=() local curl_only_skipped=0 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 [[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then curl_only_skipped=$((curl_only_skipped + 1)) continue fi offer_idx[${#offer_idx[@]}]=$i done 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 idx="${offer_idx[$((cli_choice - 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]}" else warn "${CLI_LABELS[$idx]} installation failed." fi if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then die "The selected AI CLI failed to install. Install one manually and re-run the installer." fi 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 # ============================================================================ ensure_sudo() { if [[ $EUID -eq 0 ]]; then return 0 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 "$@" 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" } # ============================================================================ # 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_start() { [[ $EUID -eq 0 ]] && return 0 [[ -n "$SUDO_KEEPALIVE_PID" ]] && 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 || die "Failed to obtain sudo privileges." else sudo -v || die "Failed to obtain sudo privileges. Try running the script directly instead of piping." 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 } # 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

Dashboard password (visible in ps; prefer CODEMAN_PASSWORD) --port Port Codeman listens on (default 3000) --help, -h This text Subcommands update Update an existing install uninstall Remove services, symlinks and (optionally) data tailscale Set up (or repair) Tailscale HTTPS access for an existing install name [] Rename this machine on your tailnet (default: codeman-) status Print the URLs, the QR code and how to manage the service cloudflared Install cloudflared for the in-app Cloudflare tunnel Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST, CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH. EOF } # Flags set the same variables their environment-variable twins do, so every # function below reads one source of truth. A flag that changes how an existing # install is reached or run also flips RECONFIGURE, so a bare re-run takes the # full flow (which re-asks nothing the flag already answered) instead of the # quiet update. A password or a port lives in the unit, so those two reconfigure # as well: the quiet update never rewrites the unit and used to drop them # silently. parse_flags() { while [[ $# -gt 0 ]]; do case "$1" in --tailscale) CODEMAN_TAILSCALE=1; CODEMAN_HOST=""; RECONFIGURE="1" ;; --lan) CODEMAN_HOST="0.0.0.0"; CODEMAN_TAILSCALE=0; RECONFIGURE="1" ;; --local) CODEMAN_HOST="127.0.0.1"; CODEMAN_TAILSCALE=0; RECONFIGURE="1" ;; --name) shift [[ $# -gt 0 ]] || die "--name needs a value (e.g. --name codeman-$(hostname -s 2>/dev/null || echo box))" TS_NAME="$1"; RECONFIGURE="1" ;; --name=*) TS_NAME="${1#--name=}"; RECONFIGURE="1" ;; --no-rename) TS_NO_RENAME="1" ;; --service) LAUNCH_PRESET="2"; RECONFIGURE="1" ;; --run) LAUNCH_PRESET="1"; RECONFIGURE="1" ;; --no-start) LAUNCH_PRESET="3" ;; --yes|-y) ASSUME_YES="1" ;; --password) shift [[ $# -gt 0 ]] || die "--password needs a value" CODEMAN_PASSWORD="$1"; RECONFIGURE="1" ;; --password=*) CODEMAN_PASSWORD="${1#--password=}"; RECONFIGURE="1" ;; --port) shift [[ $# -gt 0 ]] || die "--port needs a value" CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;; --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;; --help|-h) usage; exit 0 ;; update|uninstall|tailscale|name|status|cloudflared) [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." SUBCOMMAND="$1" ;; -*) die "Unknown option: $1 (see --help)" ;; *) if [[ "$SUBCOMMAND" == "name" && -z "$SUBCOMMAND_ARG" ]]; then SUBCOMMAND_ARG="$1" else die "Unexpected argument: $1 (see --help)" fi ;; esac shift done if [[ -n "${CODEMAN_PORT:-}" ]] && ! [[ "$CODEMAN_PORT" =~ ^[0-9]+$ && "$CODEMAN_PORT" -ge 1 && "$CODEMAN_PORT" -le 65535 ]]; then die "Invalid port: $CODEMAN_PORT" fi return 0 } # Sourcing guard: let the test harness load this file for its pure helpers # without running an install. bash 3.2 cannot be exercised any other way from # CI — see .github/workflows/ci.yml and test/install-sh-invariants.test.ts. if [[ -n "${CODEMAN_INSTALL_SH_LIB:-}" ]]; then return 0 2>/dev/null || exit 0; fi # Wrap in main to prevent partial execution on curl | bash parse_flags "$@" case "$SUBCOMMAND" in update) update ;; uninstall) uninstall ;; tailscale) setup_tailscale_subcommand ;; name) setup_name_subcommand ;; status) status_subcommand ;; cloudflared) cloudflared_subcommand ;; *) # Only a COMPLETED install re-runs as a quiet update. A partial one # (clone succeeded but build/menu never finished) lacks the marker and # re-runs the full flow, so a failed first attempt can actually finish. # A flag that changes the setup (--tailscale, --service, ...) also takes # the full flow: that is what the flag is for. if [[ "$RECONFIGURE" != "1" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then print_banner update else main fi ;; esac