Files
Codeman/install.sh
T
Codeman maintainer 687e9d7565 feat!: retire the sc tmux chooser in favour of codeman tui
scripts/tmux-chooser.sh is deleted. codeman tui replaces it and does the
job better: sc numbered its entries globally but only accepted a single
[1-9] keypress, so sessions 10+ were listed and unselectable, and it
inferred nothing about what an agent was doing. The tui carries the
server's real states, answers permission dialogs, and leaves an attach
with one key.

install.sh no longer creates the tmux-chooser symlink or the sc alias.
It now sweeps both up instead, on update AND uninstall, so an update
cannot leave a symlink pointing at a script this version stopped
shipping. The alias removal is marker-owned: it matches the exact line
the installer wrote, so someone's own 'alias sc=' for another tool is
never touched, and it rewrites through 'cat >' so the profile keeps its
mode and ownership. Verified against three profile shapes.

BREAKING CHANGE: the 'sc' command and the 'tmux-chooser' symlink are
gone. Use 'codeman tui' (and 'codeman tui --list' / 'codeman tui <n>').
2026-08-22 14:44:14 +02:00

2683 lines
92 KiB
Bash
Executable File

#!/usr/bin/env bash
# Codeman Universal Installer
# https://github.com/Ark0N/Codeman
#
# Usage: curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
#
# 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.
# 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
# (e.g. 0.0.0.0 for LAN access, 127.0.0.1 for
# local-only; interactive default is 0.0.0.0,
# non-interactive default is 127.0.0.1)
# CODEMAN_PASSWORD - Preset the dashboard password (skips the
# password prompt when binding to the network)
# CODEMAN_TAILSCALE=1 - Preset the Tailscale choice: bind loopback and
# front it with `tailscale serve` HTTPS (skips
# the network prompt; never installs Tailscale
# in non-interactive runs)
#
# 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 serve HTTPS access
# for an existing install
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
# (setup_tailscale_access / 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"
# 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}"
# Claude CLI search paths (from src/utils/claude-cli-resolver.ts)
CLAUDE_SEARCH_PATHS=(
"$HOME/.local/bin/claude"
"$HOME/.claude/local/claude"
"/usr/local/bin/claude"
"$HOME/.npm-global/bin/claude"
"$HOME/bin/claude"
)
# OpenCode CLI search paths (from src/utils/opencode-cli-resolver.ts)
OPENCODE_SEARCH_PATHS=(
"$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"
)
# Codex CLI search paths (from src/utils/codex-cli-resolver.ts)
CODEX_SEARCH_PATHS=(
"$HOME/.codex/bin/codex"
"$HOME/.local/bin/codex"
"/usr/local/bin/codex"
"$HOME/.bun/bin/codex"
"$HOME/.npm-global/bin/codex"
"$HOME/bin/codex"
)
# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts)
GEMINI_SEARCH_PATHS=(
"$HOME/.gemini/bin/gemini"
"$HOME/.local/bin/gemini"
"/usr/local/bin/gemini"
"$HOME/.bun/bin/gemini"
"$HOME/.npm-global/bin/gemini"
"$HOME/bin/gemini"
)
# Pi CLI search paths (from src/utils/pi-cli-resolver.ts)
PI_SEARCH_PATHS=(
"$HOME/.local/bin/pi"
"/usr/local/bin/pi"
"$HOME/.bun/bin/pi"
"$HOME/.npm-global/bin/pi"
"$HOME/bin/pi"
)
# Antigravity CLI search paths (from src/utils/antigravity-cli-resolver.ts)
ANTIGRAVITY_SEARCH_PATHS=(
"$HOME/.local/bin/agy"
"$HOME/.antigravity/bin/agy"
"/usr/local/bin/agy"
"$HOME/bin/agy"
)
# ============================================================================
# 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=<yours>${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 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:"
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
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
# ============================================================================
cleanup() {
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
error "Installation failed. Partial installation may remain at $INSTALL_DIR"
error "To retry, run the installer again or remove the directory manually."
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
}
check_claude() {
# Check PATH first
if command -v claude &>/dev/null; then
return 0
fi
# Check known install locations
for path in "${CLAUDE_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_claude_path() {
if command -v claude &>/dev/null; then
command -v claude
return
fi
for path in "${CLAUDE_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_opencode() {
if command -v opencode &>/dev/null; then
return 0
fi
for path in "${OPENCODE_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_opencode_path() {
if command -v opencode &>/dev/null; then
command -v opencode
return
fi
for path in "${OPENCODE_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_codex() {
if command -v codex &>/dev/null; then
return 0
fi
for path in "${CODEX_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_codex_path() {
if command -v codex &>/dev/null; then
command -v codex
return
fi
for path in "${CODEX_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_gemini() {
if command -v gemini &>/dev/null; then
return 0
fi
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_gemini_path() {
if command -v gemini &>/dev/null; then
command -v gemini
return
fi
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_antigravity() {
if command -v agy &>/dev/null; then
return 0
fi
for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_antigravity_path() {
if command -v agy &>/dev/null; then
command -v agy
return
fi
for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
# `pi` is a short, generic name (Raspberry Pi tooling, personal scripts), so the
# server-side resolver additionally probes `pi --version`. Detection here only feeds
# the "you have no AI CLI" hint, so a plain executable test is enough.
check_pi() {
if command -v pi &>/dev/null; then
return 0
fi
for path in "${PI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_pi_path() {
if command -v pi &>/dev/null; then
command -v pi
return
fi
for path in "${PI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
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
}
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 <varname>: 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 <varname>: 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 <action>: 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}"
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Non-interactive, use default
[[ "$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
}
# ============================================================================
# PATH Management
# ============================================================================
detect_shell_profile() {
local shell_name
shell_name="$(basename "${SHELL:-/bin/bash}")"
case "$shell_name" in
zsh)
if [[ -f "$HOME/.zshrc" ]]; then
echo "$HOME/.zshrc"
else
echo "$HOME/.zprofile"
fi
;;
bash)
# macOS uses .bash_profile, Linux typically uses .bashrc
if [[ "$(uname -s)" == "Darwin" ]]; then
if [[ -f "$HOME/.bash_profile" ]]; then
echo "$HOME/.bash_profile"
else
echo "$HOME/.profile"
fi
else
if [[ -f "$HOME/.bashrc" ]]; then
echo "$HOME/.bashrc"
elif [[ -f "$HOME/.bash_profile" ]]; then
echo "$HOME/.bash_profile"
else
echo "$HOME/.profile"
fi
fi
;;
fish)
echo "$HOME/.config/fish/config.fish"
;;
*)
echo "$HOME/.profile"
;;
esac
}
add_to_path() {
local bin_dir="$1"
local profile
profile=$(detect_shell_profile)
# Check if already in PATH
if [[ ":$PATH:" == *":$bin_dir:"* ]]; then
info "PATH already includes $bin_dir"
return 0
fi
# Check if already in profile
if [[ -f "$profile" ]] && grep -qF "$bin_dir" "$profile" 2>/dev/null; then
info "PATH export already in $profile"
return 0
fi
info "Adding $bin_dir to PATH in $profile"
# Create profile directory if needed (for fish)
mkdir -p "$(dirname "$profile")"
local shell_name
shell_name="$(basename "${SHELL:-/bin/bash}")"
if [[ "$shell_name" == "fish" ]]; then
echo "" >> "$profile"
echo "# Added by Codeman installer" >> "$profile"
echo "fish_add_path $bin_dir" >> "$profile"
else
echo "" >> "$profile"
echo "# Added by Codeman installer" >> "$profile"
echo "export PATH=\"$bin_dir:\$PATH\"" >> "$profile"
fi
# Also export for the current process so codeman works immediately
export PATH="$bin_dir:$PATH"
success "Added to $profile"
}
# The `sc` bash chooser was retired in favour of `codeman tui`, which reaches
# sessions 10+, carries the server's real states and leaves an attach with one
# key. Older installers wrote this alias, so take it back out.
#
# Marker-owned on purpose: it matches the exact line WE wrote, so a user's own
# `alias sc=` for something entirely different is never touched. The rewrite
# goes through `cat >` rather than `mv` so the profile keeps its own mode and
# ownership.
remove_sc_alias() {
local profile
profile=$(detect_shell_profile)
[[ -f "$profile" ]] || return 0
grep -qE "^alias sc='tmux-chooser'\$" "$profile" 2>/dev/null || return 0
local tmp
tmp=$(mktemp 2>/dev/null) || return 0
if sed -e "/^alias sc='tmux-chooser'\$/d" \
-e '/^# Codeman tmux session shortcut$/d' "$profile" > "$tmp" 2>/dev/null; then
cat "$tmp" > "$profile"
info "Removed the retired 'sc' alias from $profile (use: codeman tui)"
fi
rm -f "$tmp"
}
# ============================================================================
# Network Binding
# ============================================================================
# Best-effort LAN IP for "open this URL from your phone" hints.
detect_lan_ip() {
local ip=""
if [[ "$(uname -s)" == "Darwin" ]]; then
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)
else
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
fi
echo "${ip:-<your-ip>}"
}
# Escape a value for a quoted systemd Environment="KEY=value" assignment.
systemd_env_escape() {
printf '%s' "$1" | sed 's/[\\"]/\\&/g'
}
# Escape a value for embedding in a launchd plist <string>.
xml_escape() {
printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}
systemd_env_unescape() {
printf '%s' "$1" | sed 's/\\\(["\\]\)/\1/g'
}
xml_unescape() {
printf '%s' "$1" | sed -e 's/&lt;/</g' -e 's/&gt;/>/g' -e 's/&amp;/\&/g'
}
# Read the binding out of an already-installed service file, if any. A service
# file WITHOUT our CODEMAN_HOST line is a pre-1.8 install, which effectively
# ran loopback (the server default), so it reports 127.0.0.1.
read_existing_binding() {
EXISTING_FOUND="0"; EXISTING_HOST=""; EXISTING_PASSWORD=""; EXISTING_ACK="0"
local unit="$HOME/.config/systemd/user/codeman-web.service"
local plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if [[ -f "$unit" ]]; then
EXISTING_FOUND="1"
EXISTING_HOST=$(sed -n 's/^Environment=CODEMAN_HOST=//p' "$unit" | head -1)
local pwline
pwline=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$unit" | head -1)
[[ -n "$pwline" ]] && EXISTING_PASSWORD=$(systemd_env_unescape "$pwline")
grep -q '^Environment=CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1' "$unit" && EXISTING_ACK="1"
elif [[ -f "$plist" ]]; then
EXISTING_FOUND="1"
EXISTING_HOST=$(awk '/<key>CODEMAN_HOST<\/key>/{getline; print}' "$plist" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
local pwraw
pwraw=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$plist" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
[[ -n "$pwraw" ]] && EXISTING_PASSWORD=$(xml_unescape "$pwraw")
grep -q '<key>CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK</key>' "$plist" && EXISTING_ACK="1"
fi
if [[ "$EXISTING_FOUND" == "1" && -z "$EXISTING_HOST" ]]; then
EXISTING_HOST="127.0.0.1"
fi
return 0
}
# Ask how the dashboard should be reachable and set BIND_HOST/BIND_PASSWORD/
# BIND_ACK. Interactive default is network access (0.0.0.0) because that is
# what most installs need; loopback is offered as the safer alternative.
# Non-interactive runs keep the safe loopback default unless CODEMAN_HOST is
# preset. The server binary itself still defaults to 127.0.0.1 either way.
choose_network_binding() {
# Preset via environment: honor it and skip the prompt entirely.
# CODEMAN_TAILSCALE=1 composes with a loopback (or absent) CODEMAN_HOST.
if [[ -n "${CODEMAN_HOST:-}" ]]; then
BIND_HOST="$CODEMAN_HOST"
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
if [[ "$BIND_HOST" != "127.0.0.1" && -z "$BIND_PASSWORD" ]]; then
BIND_ACK="1"
fi
info "Network binding preset via CODEMAN_HOST: $BIND_HOST"
if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then
if [[ "$BIND_HOST" == "127.0.0.1" ]]; then
setup_tailscale_access || true
else
warn "CODEMAN_TAILSCALE=1 ignored: CODEMAN_HOST=$BIND_HOST is not loopback."
fi
fi
return 0
fi
if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then
BIND_HOST="127.0.0.1"
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
info "Tailscale access preset via CODEMAN_TAILSCALE=1"
setup_tailscale_access || true
return 0
fi
# A previous install's choice is the baseline: re-installing must never
# silently loosen it.
read_existing_binding
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
if [[ "$EXISTING_FOUND" == "1" ]]; then
BIND_HOST="$EXISTING_HOST"
BIND_PASSWORD="$EXISTING_PASSWORD"
BIND_ACK="$EXISTING_ACK"
info "Non-interactive install: preserving existing binding ($BIND_HOST)"
else
BIND_HOST="127.0.0.1"
info "Non-interactive install: binding 127.0.0.1 (preset CODEMAN_HOST=0.0.0.0 to override)"
fi
return 0
fi
# Tailscale state, for the menu hint and the default choice. Detection
# only; never installs, logs in, or prompts for sudo here.
local ts_hint="will be installed for you" ts_ready="0" ts_detected_url=""
if check_tailscale; then
ts_hint="installed, needs login"
if command -v node &>/dev/null && [[ "$(ts_status_field 's.BackendState')" == "Running" ]]; then
ts_ready="1"
ts_hint="already connected"
ts_detected_url=$(detect_tailscale_serve_url) || ts_detected_url=""
if [[ -n "$ts_detected_url" ]]; then
ts_hint="already serving Codeman"
fi
fi
fi
# Defaults: an existing setup wins (existing loopback installs default to
# Tailscale only when its serve mapping is already present); fresh installs
# default to Tailscale when it is already connected, else network access.
# A bare Enter never pulls in new software.
local default_choice="2"
if [[ "$EXISTING_FOUND" == "1" && "$EXISTING_HOST" == "127.0.0.1" ]]; then
if [[ -n "$ts_detected_url" ]]; then
default_choice="1"
else
default_choice="3"
fi
elif [[ "$EXISTING_FOUND" != "1" && "$ts_ready" == "1" ]]; then
default_choice="1"
fi
echo -e " ${BOLD}Network access${NC}"
echo ""
echo -e " How should the Codeman dashboard be reachable?"
echo ""
echo -e " ${CYAN}1)${NC} ${BOLD}Tailscale${NC} ${DIM}($ts_hint)${NC}"
echo -e " Private VPN access from your phone or laptop, anywhere."
echo -e " Real HTTPS, no password needed: your tailnet is the login."
echo -e " ${CYAN}2)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC}"
echo -e " Open it straight from your phone or laptop on the same Wi-Fi."
echo -e " ${YELLOW}Less safe: set a password so only you control your agents.${NC}"
echo -e " ${CYAN}3)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}"
echo -e " Safest. Reach it remotely via Tailscale or a tunnel later."
echo ""
if [[ "$EXISTING_FOUND" == "1" ]]; then
echo -e " ${DIM}Current setup: $EXISTING_HOST$([[ -n "$EXISTING_PASSWORD" ]] && echo ", password set"). Enter keeps it.${NC}"
echo ""
fi
local bind_choice=""
while true; do
echo -en "${CYAN}Choose [1/2/3] (default $default_choice):${NC} " >&2
read_reply bind_choice || bind_choice="$default_choice"
bind_choice="${bind_choice:-$default_choice}"
case "$bind_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
esac
done
if [[ "$bind_choice" == "3" ]]; then
BIND_HOST="127.0.0.1"
success "Binding 127.0.0.1 (this machine only)"
return 0
fi
if [[ "$bind_choice" == "1" ]]; then
BIND_HOST="127.0.0.1"
setup_tailscale_access || true
# Password is optional here: the tailnet already authenticates devices.
# An existing password is always kept (never silently loosen).
if [[ -n "$EXISTING_PASSWORD" ]]; then
BIND_PASSWORD="$EXISTING_PASSWORD"
info "Keeping the existing dashboard password"
elif [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
BIND_PASSWORD="$CODEMAN_PASSWORD"
info "Using CODEMAN_PASSWORD from the environment"
elif prompt_yes_no "Add a dashboard password too? (optional; your tailnet already authenticates your devices)" "n"; then
local ts_pw="" ts_pw2=""
while true; do
echo -en "${CYAN}Dashboard password:${NC} " >&2
read_secret ts_pw || ts_pw=""
if [[ -z "$ts_pw" ]]; then
info "No password set"
break
fi
echo -en "${CYAN}Confirm password:${NC} " >&2
read_secret ts_pw2 || ts_pw2=""
if [[ "$ts_pw" == "$ts_pw2" ]]; then
BIND_PASSWORD="$ts_pw"
success "Password set (login user: admin)"
break
fi
echo "Passwords do not match, try again." >&2
done
fi
return 0
fi
# Keep a custom non-loopback host from a previous install (e.g. a specific
# interface IP); otherwise bind all interfaces.
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
BIND_HOST="$EXISTING_HOST"
else
BIND_HOST="0.0.0.0"
fi
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
BIND_PASSWORD="$CODEMAN_PASSWORD"
info "Using CODEMAN_PASSWORD from the environment"
return 0
fi
echo ""
local pw="" pw2="" keep_hint=""
[[ -n "$EXISTING_PASSWORD" ]] && keep_hint="Enter to keep the current one" || keep_hint="Enter to skip"
while true; do
echo -en "${CYAN}Set a dashboard password (recommended; $keep_hint):${NC} " >&2
read_secret pw || pw=""
if [[ -z "$pw" ]]; then
if [[ -n "$EXISTING_PASSWORD" ]]; then
BIND_PASSWORD="$EXISTING_PASSWORD"
success "Keeping the existing password"
break
fi
echo ""
warn "Without a password, EVERY device on your network gets full access"
warn "to your agents (they run commands as $USER)."
if prompt_yes_no "Continue WITHOUT a password?" "n"; then
BIND_ACK="1"
break
fi
continue
fi
echo -en "${CYAN}Confirm password:${NC} " >&2
read_secret pw2 || pw2=""
if [[ "$pw" == "$pw2" ]]; then
BIND_PASSWORD="$pw"
success "Password set (login user: admin)"
break
fi
echo "Passwords do not match, try again." >&2
done
return 0
}
# ============================================================================
# Tailscale Access (loopback bind fronted by `tailscale serve` HTTPS)
# ============================================================================
# The recommended remote-access setup: Codeman stays on 127.0.0.1 and
# tailscaled fronts it with a real Let's Encrypt certificate for
# https://<node>.<tailnet>.ts.net, reachable from the user's tailnet only.
# The app side needs zero configuration (.ts.net is in the server's trusted
# host suffixes). All state lives in tailscaled: no marker files, `tailscale
# serve status` is the single source of truth, and `--bg` config persists
# across reboots on its own.
#
# Safety rule for every function here: NEVER `tailscale serve reset` and never
# touch mappings other than 443 -> Codeman's port. Users may have unrelated
# serve config (other ports, other apps) that a reset would destroy.
get_tailscale_path() {
if command -v tailscale &>/dev/null; then
command -v tailscale
return 0
fi
# macOS GUI app (App Store or brew cask) ships the CLI inside the bundle
# and does not put it on PATH.
if [[ -x "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]]; then
echo "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
return 0
fi
return 1
}
check_tailscale() {
get_tailscale_path >/dev/null 2>&1
}
ts_cmd() {
local ts_bin
ts_bin=$(get_tailscale_path) || return 127
"$ts_bin" "$@"
}
# Serve mutations need root or "operator" rights on Linux; TS_NEED_ROOT is set
# by ensure_tailscale_operator when the operator grant failed. Detection paths
# run with TS_NEED_ROOT=0 and must never trigger a sudo prompt.
ts_cmd_serve() {
local ts_bin
ts_bin=$(get_tailscale_path) || return 127
if [[ "$TS_NEED_ROOT" == "1" ]]; then
run_as_root "$ts_bin" "$@"
else
"$ts_bin" "$@"
fi
}
# ts_status_field <js-expr>: evaluate an expression against the parsed
# `tailscale status --json` object bound to `s`, printing the result (empty on
# any error). node is guaranteed at every call site (the installer installs it
# before the binding prompt; the subcommand requires a completed install).
ts_status_field() {
ts_cmd status --json 2>/dev/null | node -e '
let d = "";
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () => {
try {
const s = JSON.parse(d);
const v = eval(process.argv[1]);
if (v !== undefined && v !== null && v !== false) process.stdout.write(String(v));
} catch {}
});
' "$1" 2>/dev/null
}
# Print the local port that the :443 web handler proxies to, empty when 443 is
# unconfigured. Any scheme counts (http://, and https+insecure:// from setups
# where Codeman itself runs --https), so legacy configs are recognized as ours.
ts_serve_443_target_port() {
ts_cmd_serve serve status --json 2>/dev/null | node -e '
let d = "";
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () => {
try {
const s = JSON.parse(d);
for (const [hostport, cfg] of Object.entries(s.Web || {})) {
if (!hostport.endsWith(":443")) continue;
const proxy = cfg && cfg.Handlers && cfg.Handlers["/"] && cfg.Handlers["/"].Proxy;
if (!proxy) continue;
const m = String(proxy).match(/:(\d+)\/?$/);
if (m) process.stdout.write(m[1]);
return;
}
} catch {}
});
' 2>/dev/null
}
# Print https://<node>.<tailnet>.ts.net when tailscale is running AND serve
# already forwards 443 to Codeman's port; print nothing otherwise. Safe to call
# anywhere (no sudo, no side effects); used by the security notice, uninstall,
# and the re-run default.
detect_tailscale_serve_url() {
check_tailscale || return 0
command -v node &>/dev/null || return 0
[[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0
local port="${CODEMAN_PORT:-3000}"
[[ "$(ts_serve_443_target_port)" == "$port" ]] || return 0
local dns
dns=$(ts_status_field 's.Self && s.Self.DNSName')
[[ -n "$dns" ]] || return 0
echo "https://${dns%.}"
}
tailscale_retrofit_hint() {
warn "$1: falling back to local-only access (127.0.0.1)."
echo -e " ${DIM}Set up Tailscale access any time later with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}" >&2
}
offer_install_tailscale() {
if [[ "$NONINTERACTIVE" == "1" ]]; then
info "Tailscale is not installed; skipping (non-interactive runs never install it)."
return 1
fi
headless_guard "install Tailscale (curl | sh from tailscale.com)"
if [[ "$(uname -s)" == "Darwin" ]]; then
if command -v brew &>/dev/null; then
if ! prompt_yes_no "Tailscale is not installed. Install it now with Homebrew?" "y"; then
return 1
fi
if ! brew install --cask tailscale; then
warn "Homebrew install failed."
return 1
fi
open -a Tailscale 2>/dev/null || true
info "Log in via the Tailscale menu-bar app if it asks."
else
info "Install the Tailscale app first: https://tailscale.com/download/macos"
if ! prompt_yes_no "Continue once Tailscale is installed?" "n"; then
return 1
fi
fi
else
if ! prompt_yes_no "Tailscale is not installed. Install it now (official installer from tailscale.com)?" "y"; then
return 1
fi
info "Running the official Tailscale installer (it may ask for sudo)..."
# When piped (curl | bash), stdin is our pipe: give the child installer
# the real terminal so its own sudo prompt works.
if [[ -e /dev/tty ]]; then
if ! sh -c "$(download_to_stdout https://tailscale.com/install.sh)" < /dev/tty; then
warn "Tailscale installation failed."
return 1
fi
else
if ! sh -c "$(download_to_stdout https://tailscale.com/install.sh)"; then
warn "Tailscale installation failed."
return 1
fi
fi
fi
if ! check_tailscale; then
warn "tailscale was not found after the install."
return 1
fi
success "Tailscale installed"
return 0
}
ensure_tailscale_login() {
local state
state=$(ts_status_field 's.BackendState')
if [[ "$state" == "Running" ]]; then
return 0
fi
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
warn "Tailscale is installed but not connected (state: ${state:-unknown})."
return 1
fi
info "Tailscale needs to log in to your tailnet."
echo -e " ${DIM}A login URL will be printed: open it on any device. Waiting up to 5 minutes.${NC}"
local ts_bin up_ok="0"
ts_bin=$(get_tailscale_path) || return 1
if [[ "$(uname -s)" == "Darwin" ]]; then
# The GUI app's CLI runs as the user; no root needed.
if "$ts_bin" up --timeout=300s; then up_ok="1"; fi
else
if [[ -e /dev/tty ]]; then
if run_as_root "$ts_bin" up --timeout=300s < /dev/tty; then up_ok="1"; fi
else
if run_as_root "$ts_bin" up --timeout=300s; then up_ok="1"; fi
fi
fi
if [[ "$up_ok" != "1" ]]; then
if [[ "$(uname -s)" == "Darwin" ]]; then
info "If the CLI cannot log in, open the Tailscale app, log in there, then run:"
info " bash $INSTALL_DIR/install.sh tailscale"
fi
return 1
fi
[[ "$(ts_status_field 's.BackendState')" == "Running" ]]
}
# Linux: `tailscale serve` needs root or operator rights. Grant operator once
# (with the user's consent via sudo) so serve config never needs sudo again;
# fall back to sudo-per-command when the grant fails.
ensure_tailscale_operator() {
if [[ "$(uname -s)" == "Darwin" ]] || [[ $EUID -eq 0 ]]; then
return 0
fi
if ts_cmd serve status &>/dev/null; then
return 0
fi
if ! command -v sudo &>/dev/null; then
warn "No sudo available; tailscale serve configuration may fail without root."
TS_NEED_ROOT="1"
return 0
fi
info "Granting your user Tailscale 'operator' rights (one-time sudo; lets serve run without root)..."
local ts_bin
ts_bin=$(get_tailscale_path) || return 0
if run_as_root "$ts_bin" set --operator="$USER" 2>/dev/null && ts_cmd serve status &>/dev/null; then
success "Operator rights granted"
return 0
fi
warn "Could not grant operator rights; serve commands will use sudo."
TS_NEED_ROOT="1"
return 0
}
# HTTPS certificates are a per-tailnet admin toggle. Serve without them cannot
# terminate TLS, and a plain-HTTP fallback would silently break the "real
# HTTPS" promise (PWA install, web push), so guide the user through enabling
# them instead of degrading.
ensure_tailnet_https() {
while true; do
local magic cert
magic=$(ts_status_field 's.CurrentTailnet && s.CurrentTailnet.MagicDNSEnabled ? "1" : ""')
cert=$(ts_status_field 'Array.isArray(s.CertDomains) && s.CertDomains.length > 0 ? "1" : ""')
if [[ "$magic" == "1" && "$cert" == "1" ]]; then
return 0
fi
warn "Your tailnet has not enabled HTTPS certificates yet (a one-time admin toggle)."
echo -e " Open ${CYAN}https://login.tailscale.com/admin/dns${NC} and enable:" >&2
if [[ "$magic" == "1" ]]; then
echo -e " ${CYAN}1.${NC} MagicDNS ${GREEN}(already on)${NC}" >&2
else
echo -e " ${CYAN}1.${NC} MagicDNS" >&2
fi
if [[ "$cert" == "1" ]]; then
echo -e " ${CYAN}2.${NC} HTTPS Certificates ${GREEN}(already on)${NC}" >&2
else
echo -e " ${CYAN}2.${NC} HTTPS Certificates" >&2
fi
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
return 1
fi
if ! prompt_yes_no "Re-check now? (answering no skips Tailscale setup)" "y"; then
return 1
fi
done
}
setup_tailscale_serve() {
local port="${CODEMAN_PORT:-3000}"
local dns url existing
dns=$(ts_status_field 's.Self && s.Self.DNSName')
if [[ -z "$dns" ]]; then
warn "Could not determine this machine's tailnet DNS name."
return 1
fi
url="https://${dns%.}"
existing=$(ts_serve_443_target_port)
if [[ "$existing" == "$port" ]]; then
TAILSCALE_SERVE_URL="$url"
success "Tailscale serve already forwards $url to port $port (kept as-is)"
return 0
fi
if [[ -n "$existing" ]]; then
warn "tailscale serve already forwards $url (port 443) to local port $existing."
if ! prompt_yes_no "Replace that mapping with Codeman (port $port)?" "n"; then
info "Keeping the existing mapping."
return 1
fi
fi
info "Configuring: tailscale serve --bg $port"
local serve_out
if serve_out=$(ts_cmd_serve serve --bg "$port" 2>&1); then
TAILSCALE_SERVE_URL="$url"
success "Tailscale HTTPS enabled: $url"
echo -e " ${DIM}(persists across reboots; inspect with: tailscale serve status)${NC}"
return 0
fi
warn "tailscale serve failed:"
printf '%s\n' "$serve_out" | sed 's/^/ /' >&2
return 1
}
# Curl the ts.net URL until it answers. 200 = reachable; 401 = reachable behind
# the dashboard password. The first request can be slow while tailscaled
# obtains the Let's Encrypt certificate.
verify_tailscale_access() {
if [[ -z "$TAILSCALE_SERVE_URL" ]]; then
return 0
fi
if ! command -v curl &>/dev/null; then
info "curl not available; open $TAILSCALE_SERVE_URL to verify."
return 0
fi
info "Verifying $TAILSCALE_SERVE_URL (first load can take ~30s while the HTTPS certificate is issued)..."
local i http_code
for ((i = 1; i <= 10; i++)); do
http_code=$(curl -skm 10 -o /dev/null -w '%{http_code}' "$TAILSCALE_SERVE_URL/api/status" 2>/dev/null) || http_code=""
if [[ "$http_code" == "200" || "$http_code" == "401" ]]; then
success "Reachable: $TAILSCALE_SERVE_URL"
return 0
fi
sleep 3
done
warn "Could not reach $TAILSCALE_SERVE_URL/api/status yet."
warn "It may need another minute (certificate issuance). Inspect: tailscale serve status"
warn "If Codeman itself runs with --https, the serve target must be:"
warn " tailscale serve --bg https+insecure://localhost:${CODEMAN_PORT:-3000}"
return 1
}
# Orchestrator: walk every state (not installed -> logged out -> operator ->
# tailnet HTTPS -> serve) and end with TAILSCALE_SERVE_URL set, or fall back
# gracefully (the caller keeps the loopback bind either way).
setup_tailscale_access() {
TAILSCALE_SERVE_URL=""
if ! check_tailscale; then
if ! offer_install_tailscale; then
tailscale_retrofit_hint "Tailscale is not installed"
return 1
fi
fi
if ! command -v node &>/dev/null; then
tailscale_retrofit_hint "node is not on PATH yet"
return 1
fi
if ! ensure_tailscale_login; then
tailscale_retrofit_hint "Tailscale is not connected"
return 1
fi
ensure_tailscale_operator
if ! ensure_tailnet_https; then
tailscale_retrofit_hint "HTTPS certificates are not enabled for your tailnet"
return 1
fi
if ! setup_tailscale_serve; then
tailscale_retrofit_hint "tailscale serve could not be configured"
return 1
fi
return 0
}
# `install.sh tailscale`: retrofit Tailscale access onto an existing install
# (also the target of every "set it up later" hint above).
setup_tailscale_subcommand() {
print_banner
if ! command -v node &>/dev/null; then
die "node is required. Install Codeman first (run the installer without arguments)."
fi
read_existing_binding
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
warn "Your service binds $EXISTING_HOST (network-wide). Tailscale serve will work, but the"
warn "dashboard stays reachable on your LAN too. Re-run the installer and choose Tailscale"
warn "to switch to the tighter loopback-only bind."
echo ""
fi
if ! setup_tailscale_access; then
exit 1
fi
# Verify end-to-end only when Codeman is actually answering locally.
local port="${CODEMAN_PORT:-3000}" server_up="0"
if command -v curl &>/dev/null; then
if curl -skm 5 -o /dev/null "http://127.0.0.1:$port/api/status" 2>/dev/null ||
curl -skm 5 -o /dev/null "https://127.0.0.1:$port/api/status" 2>/dev/null; then
server_up="1"
fi
fi
if [[ "$server_up" == "1" ]]; then
verify_tailscale_access || true
else
info "Codeman does not appear to be running on port $port right now."
info "Once it is, open: $TAILSCALE_SERVE_URL"
fi
BIND_HOST="${EXISTING_HOST:-127.0.0.1}"
BIND_PASSWORD="$EXISTING_PASSWORD"
print_security_notice
}
# ============================================================================
# Service Setup (Linux systemd / macOS launchd)
# ============================================================================
# Wait briefly for codeman-web.service to report active. A bad node path or a
# busy port makes the unit crash within the first seconds (then sit in
# activating/auto-restart), so a blind "started!" message would be a lie.
verify_systemd_active() {
local attempt
for attempt in 1 2 3; do
sleep 2
if systemctl --user is-active --quiet codeman-web.service 2>/dev/null; then
return 0
fi
done
return 1
}
setup_launchd_service() {
local plist_label="com.codeman.web"
local agent_dir="$HOME/Library/LaunchAgents"
local agent_plist="$agent_dir/$plist_label.plist"
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
info "Setting up macOS LaunchAgent..."
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
# inherits the user's environment, and is the correct choice for user apps.
if [[ -f "$daemon_plist" ]]; then
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist"
success "Removed duplicate LaunchDaemon"
fi
# Unload existing agent before overwriting
if [[ -f "$agent_plist" ]]; then
launchctl unload "$agent_plist" 2>/dev/null || true
fi
mkdir -p "$agent_dir"
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# Find node binary path
local node_path
node_path=$(command -v node)
# Binding chosen during install (empty on paths that never asked)
local bind_plist=""
if [[ -n "$BIND_HOST" ]]; then
bind_plist=" <key>CODEMAN_HOST</key>
<string>$BIND_HOST</string>"
if [[ -n "$BIND_PASSWORD" ]]; then
bind_plist+=$'\n'" <key>CODEMAN_PASSWORD</key>
<string>$(xml_escape "$BIND_PASSWORD")</string>"
fi
if [[ "$BIND_ACK" == "1" ]]; then
bind_plist+=$'\n'" <key>CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK</key>
<string>1</string>"
fi
fi
cat > "$agent_plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$plist_label</string>
<key>ProgramArguments</key>
<array>
<string>$node_path</string>
<string>$INSTALL_DIR/dist/index.js</string>
<string>web</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>$svc_path</string>
<key>HOME</key>
<string>$HOME</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
$bind_plist
</dict>
<key>WorkingDirectory</key>
<string>$HOME</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/codeman.log</string>
<key>StandardErrorPath</key>
<string>/tmp/codeman.log</string>
</dict>
</plist>
EOF
launchctl load "$agent_plist" 2>/dev/null || true
# launchctl load is silent about many failures: confirm the agent is loaded
sleep 2
if launchctl list "$plist_label" &>/dev/null; then
success "LaunchAgent installed and started"
return 0
fi
warn "LaunchAgent did not load."
warn "Inspect: launchctl list | grep codeman ; tail -20 /tmp/codeman.log"
return 1
}
setup_systemd_service() {
local service_dir="$HOME/.config/systemd/user"
local service_file="$service_dir/codeman-web.service"
info "Setting up systemd user service..."
mkdir -p "$service_dir"
# Find node binary path
local node_path
node_path=$(command -v node)
# Binding chosen during install (empty on paths that never asked)
local bind_env=""
if [[ -n "$BIND_HOST" ]]; then
bind_env="Environment=CODEMAN_HOST=$BIND_HOST"
if [[ -n "$BIND_PASSWORD" ]]; then
bind_env+=$'\n'"Environment=\"CODEMAN_PASSWORD=$(systemd_env_escape "$BIND_PASSWORD")\""
fi
if [[ "$BIND_ACK" == "1" ]]; then
bind_env+=$'\n'"Environment=CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1"
fi
fi
# Create service file
cat > "$service_file" << EOF
[Unit]
Description=Codeman Web Server
After=network.target
[Service]
Type=simple
ExecStart=$node_path $INSTALL_DIR/dist/index.js web
WorkingDirectory=$HOME
Restart=always
RestartSec=10
Environment=NODE_ENV=production
Environment=PATH=$PATH
$bind_env
[Install]
WantedBy=default.target
EOF
# Reload systemd. A user D-Bus session is required for systemctl --user
# (missing under bare `ssh host 'curl | bash'` provisioning), so detect
# that up front instead of dying mid-setup with a cryptic trap message.
if ! systemctl --user daemon-reload 2>/dev/null; then
warn "systemctl --user is unavailable (no user D-Bus session?); cannot manage user services here."
warn "Unit written to $service_file. From a normal login shell, enable it with:"
warn " systemctl --user daemon-reload && systemctl --user enable --now codeman-web"
return 1
fi
# Enable service
systemctl --user enable codeman-web.service 2>/dev/null || true
# Enable lingering (allows service to run after logout)
if command -v loginctl &>/dev/null; then
loginctl enable-linger "$USER" 2>/dev/null || true
fi
# (Re)start the service. restart, not start: on a re-run over an existing
# running service, start would be a no-op and leave the OLD build running.
systemctl --user restart codeman-web.service 2>/dev/null || true
if verify_systemd_active; then
success "Systemd service installed and started"
return 0
fi
warn "codeman-web.service did not become active."
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
return 1
}
setup_tunnel_service() {
local service_dir="$HOME/.config/systemd/user"
local service_file="$service_dir/codeman-tunnel.service"
info "Setting up Cloudflare tunnel systemd service..."
mkdir -p "$service_dir"
cp "$INSTALL_DIR/scripts/codeman-tunnel.service" "$service_file"
systemctl --user daemon-reload
systemctl --user enable codeman-tunnel.service 2>/dev/null || true
success "Tunnel service installed (start with: systemctl --user start codeman-tunnel)"
echo -e " ${DIM}Note: Set CODEMAN_PASSWORD env var before starting the tunnel for security.${NC}"
}
# ============================================================================
# Installation Helpers
# ============================================================================
install_dependency() {
local dep_name="$1"
local os="$2"
local distro="$3"
local install_func="install_${dep_name}_${distro:-$os}"
# Try distro-specific first, then OS-level
if [[ "$os" == "macos" ]]; then
install_func="install_${dep_name}_macos"
elif ! declare -f "$install_func" &>/dev/null; then
die "Don't know how to install $dep_name on $distro. Please install it manually."
fi
"$install_func"
}
# ============================================================================
# Main Installation
# ============================================================================
print_banner() {
echo -e "${CYAN}${BOLD}"
cat << 'EOF'
____ _
/ ___|___ __| | ___ _ __ ___ __ _ _ __
| | / _ \ / _` |/ _ \ '_ ` _ \ / _` | '_ \
| |__| (_) | (_| | __/ | | | | | (_| | | | |
\____\___/ \__,_|\___|_| |_| |_|\__,_|_| |_|
EOF
echo -e "${NC}${DIM} The missing control plane for Claude Code${NC}"
echo ""
}
main() {
print_banner
# Check for curl/wget first
if ! check_curl_or_wget; then
die "curl or wget is required but neither is installed. Please install one first."
fi
# Detect system
local os arch distro=""
os=$(detect_os)
arch=$(detect_arch)
if [[ "$os" == "linux" ]]; then
distro=$(detect_linux_distro)
fi
info "Detected: $os ($arch)${distro:+ - $distro}"
echo ""
# ========================================================================
# Check/Install Dependencies
# ========================================================================
# Git
info "Checking Git..."
if ! check_git; then
headless_guard "install Git (system package via sudo)"
if prompt_yes_no "Git is not installed. Install it now?"; then
install_dependency "git" "$os" "$distro"
else
die "Git is required to install Codeman."
fi
else
success "Git is installed"
fi
# Node.js
info "Checking Node.js (v$MIN_NODE_VERSION+)..."
if ! check_node; then
local node_version=""
if command -v node &>/dev/null; then
node_version=$(node --version 2>/dev/null || echo "unknown")
warn "Node.js $node_version is installed but version $MIN_NODE_VERSION+ is required."
fi
headless_guard "install Node.js v$TARGET_NODE_VERSION (system package via sudo)"
if prompt_yes_no "Install Node.js v$TARGET_NODE_VERSION?"; then
install_dependency "node" "$os" "$distro"
# Rehash to pick up new node
hash -r 2>/dev/null || true
else
die "Node.js $MIN_NODE_VERSION+ is required to run Codeman."
fi
else
local node_ver
node_ver=$(node --version 2>/dev/null)
success "Node.js $node_ver is installed"
fi
# Verify npm (should come with Node.js)
if ! check_npm; then
die "npm is not available. Please reinstall Node.js."
fi
# Terminal multiplexer (tmux required)
info "Checking tmux..."
if check_tmux; then
success "tmux is installed"
else
headless_guard "install tmux (system package via sudo)"
if prompt_yes_no "tmux is not installed. Install it now?"; then
install_dependency "tmux" "$os" "$distro"
else
die "tmux is required for session persistence."
fi
fi
# AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi)
local has_claude=false
local has_opencode=false
local has_codex=false
local has_gemini=false
local has_antigravity=false
local has_pi=false
info "Checking AI CLI tools..."
if check_claude; then
has_claude=true
success "Claude Code found at $(get_claude_path)"
fi
if check_opencode; then
has_opencode=true
success "OpenCode found at $(get_opencode_path)"
fi
if check_codex; then
has_codex=true
success "Codex found at $(get_codex_path)"
fi
if check_gemini; then
has_gemini=true
success "Gemini CLI found at $(get_gemini_path)"
fi
if check_antigravity; then
has_antigravity=true
success "Antigravity CLI found at $(get_antigravity_path)"
fi
if check_pi; then
has_pi=true
success "Pi CLI found at $(get_pi_path)"
fi
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
echo -e " ${CYAN}3)${NC} Both"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity or Pi)"
echo ""
local cli_choice=""
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Explicit automation opt-in: default to Claude Code
cli_choice="1"
info "CODEMAN_NONINTERACTIVE=1: defaulting to Claude Code"
else
while true; do
echo -en "${CYAN}Choose [1/2/3/4]:${NC} " >&2
read_reply cli_choice || { cli_choice="1"; break; }
case "$cli_choice" in
1|2|3|4) break ;;
*) echo "Please enter 1, 2, 3, or 4." >&2 ;;
esac
done
fi
if [[ "$cli_choice" == "1" ]] || [[ "$cli_choice" == "3" ]]; then
info "Installing Claude Code CLI..."
download_to_stdout https://claude.ai/install.sh | bash
hash -r 2>/dev/null || true
if check_claude; then
has_claude=true
success "Claude Code installed at $(get_claude_path)"
else
warn "Claude Code installation failed."
fi
fi
if [[ "$cli_choice" == "2" ]] || [[ "$cli_choice" == "3" ]]; then
info "Installing OpenCode CLI..."
download_to_stdout https://opencode.ai/install | bash
hash -r 2>/dev/null || true
if check_opencode; then
has_opencode=true
success "OpenCode installed at $(get_opencode_path)"
else
warn "OpenCode installation failed."
fi
fi
if [[ "$cli_choice" == "4" ]]; then
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
info "Install one later, e.g.: npm install -g @openai/codex (Codex)"
info " or: curl -fsSL https://antigravity.google/cli/install.sh | bash (Antigravity)"
info " or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)"
elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
fi
# cloudflared (optional — for remote/mobile access via Cloudflare Tunnel)
info "Checking cloudflared (optional, for remote access)..."
if check_cloudflared; then
success "cloudflared found at $(get_cloudflared_path)"
else
if prompt_yes_no "Install cloudflared? (enables remote/mobile access via Cloudflare Tunnel)" "n"; then
install_dependency "cloudflared" "$os" "$distro"
hash -r 2>/dev/null || true
if check_cloudflared; then
success "cloudflared installed at $(get_cloudflared_path)"
else
warn "cloudflared installation failed. You can install it manually later."
fi
else
info "Skipped (you can install cloudflared later for remote access)"
fi
fi
echo ""
# ========================================================================
# Clone/Update Repository
# ========================================================================
info "Installing Codeman to $INSTALL_DIR..."
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Existing installation found, updating..."
cd "$INSTALL_DIR"
git remote set-url origin "$REPO_URL" 2>/dev/null || true
# Check for local changes
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
warn "Local changes detected in $INSTALL_DIR"
if prompt_yes_no "Discard local changes and update?" "n"; then
git fetch --quiet origin
git reset --hard "origin/$BRANCH" --quiet
else
info "Keeping existing installation, skipping update"
fi
else
git fetch --quiet origin
git reset --hard "origin/$BRANCH" --quiet
fi
else
# Create parent directory
mkdir -p "$(dirname "$INSTALL_DIR")"
# Clone repository (shallow for speed)
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
success "Repository ready"
# ========================================================================
# Build
# ========================================================================
info "Installing dependencies..."
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
info "Building..."
npm run build --quiet 2>/dev/null || npm run build
success "Build complete"
# ========================================================================
# Add to PATH
# ========================================================================
# Create symlink in a common PATH location
local symlink_dir="$HOME/.local/bin"
mkdir -p "$symlink_dir" 2>/dev/null || true
if [[ -d "$symlink_dir" ]]; then
ln -sf "$INSTALL_DIR/dist/index.js" "$symlink_dir/codeman"
info "Created symlink: $symlink_dir/codeman"
# tmux-chooser/`sc` is retired; `codeman tui` replaces it. Sweep up what
# an older installer left behind, so an update does not leave a symlink
# pointing at a script this version no longer ships.
if [[ -L "$symlink_dir/tmux-chooser" ]]; then
rm -f "$symlink_dir/tmux-chooser"
info "Removed the retired tmux-chooser symlink (use: codeman tui)"
fi
remove_sc_alias
# Add ~/.local/bin to PATH if not already there
if [[ ":$PATH:" != *":$symlink_dir:"* ]]; then
add_to_path "$symlink_dir"
fi
fi
# ========================================================================
# Mark install complete
# ========================================================================
# The dispatcher at the bottom only routes a bare re-run to the quiet
# update path when this marker exists, so an aborted first install
# (failed npm install/build, Ctrl+C) re-runs the full setup flow
# (symlinks, PATH, launch menu) instead of silently "updating".
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
# ========================================================================
# Launch Options
# ========================================================================
echo ""
echo -e "${GREEN}${BOLD}============================================================${NC}"
echo -e "${GREEN}${BOLD} Codeman installed successfully!${NC}"
echo -e "${GREEN}${BOLD}============================================================${NC}"
echo ""
# Ask how the dashboard should be reachable BEFORE the launch menu, so the
# service files and the run-now path all inherit the choice.
choose_network_binding
echo ""
local launch_choice=""
local has_service=false
local service_type=""
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
has_service=true
service_type="systemd"
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
has_service=true
service_type="launchd"
fi
if [[ "$has_service" == "true" ]]; then
local service_label="systemd service"
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
echo ""
echo -e " ${CYAN}1)${NC} Run now in this terminal"
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
launch_choice="3"
info "No interactive terminal detected: not starting (run 'codeman web' when ready)"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read_reply launch_choice || { launch_choice="3"; break; }
case "$launch_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
esac
done
fi
else
# No service manager available — only offer run now or skip
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
echo ""
echo -e " ${CYAN}1)${NC} Run now in this terminal"
echo -e " ${CYAN}2)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
launch_choice="2"
info "No interactive terminal detected: not starting (run 'codeman web' when ready)"
else
while true; do
echo -en "${CYAN}Choose [1/2]:${NC} " >&2
read_reply launch_choice || { launch_choice="2"; break; }
case "$launch_choice" in
1) break ;;
2) break ;;
*) echo "Please enter 1 or 2." >&2 ;;
esac
done
fi
# Remap: no-systemd choice "2" (skip) → internal "3"
[[ "$launch_choice" == "2" ]] && launch_choice="3"
fi
echo ""
# Handle service setup
if [[ "$launch_choice" == "2" ]]; then
local service_ok=true
if [[ "$service_type" == "launchd" ]]; then
setup_launchd_service || service_ok=false
else
setup_systemd_service || service_ok=false
fi
# Offer tunnel service if cloudflared is available (Linux only: systemd tunnel service).
# Skipped when service setup failed: it needs the same systemctl --user access.
if [[ "$service_ok" == "true" ]] && [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
echo ""
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
setup_tunnel_service
fi
fi
echo ""
if [[ "$service_ok" == "true" ]]; then
# With Tailscale configured, prove the URL actually answers now
# that the server is up (never claim success blindly).
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
verify_tailscale_access || true
echo ""
fi
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
echo -e " $TAILSCALE_SERVE_URL ${DIM}(any device on your tailnet, HTTPS)${NC}"
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
else
echo -e " http://localhost:3000"
fi
else
echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see warnings above)."
echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}codeman web${NC}"
fi
echo ""
echo -e " ${BOLD}Manage the service:${NC}"
echo ""
if [[ "$service_type" == "launchd" ]]; then
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
else
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
fi
echo ""
fi
# Show quick-start help for non-service paths
if [[ "$launch_choice" != "2" ]]; then
echo -e " ${BOLD}Quick Start:${NC}"
echo ""
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
if [[ -n "$BIND_PASSWORD" ]]; then
echo -e " ${CYAN}CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' codeman web${NC}"
else
echo -e " ${CYAN}CODEMAN_HOST=0.0.0.0 codeman web${NC}"
fi
echo -e " ${DIM}(a bare 'codeman web' binds 127.0.0.1, this machine only)${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
else
echo -e " ${CYAN}codeman web${NC} # Start the web server"
echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
echo -e " $TAILSCALE_SERVE_URL ${DIM}(any device on your tailnet, once running)${NC}"
fi
fi
echo ""
fi
if [[ -n "$TAILSCALE_SERVE_URL" ]]; then
echo -e " ${BOLD}Remote Access (Tailscale):${NC}"
echo ""
echo -e " $TAILSCALE_SERVE_URL ${DIM}(HTTPS, any device on your tailnet)${NC}"
echo -e " ${CYAN}tailscale serve status${NC} # Inspect the mapping"
echo ""
fi
if check_cloudflared; then
echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}"
echo ""
echo -e " ${CYAN}./scripts/tunnel.sh start${NC} # Start tunnel"
echo -e " ${CYAN}./scripts/tunnel.sh url${NC} # Show tunnel URL"
echo -e " ${CYAN}./scripts/tunnel.sh stop${NC} # Stop tunnel"
echo ""
fi
echo -e " ${BOLD}Mobile Access (Termius/SSH):${NC}"
echo ""
echo -e " ${CYAN}codeman tui${NC} # Full-screen session dashboard"
echo -e " ${CYAN}codeman tui 2${NC} # Attach straight to session 2"
echo -e " ${CYAN}codeman tui -l${NC} # Numbered list, then exit"
echo ""
echo -e " ${BOLD}Documentation:${NC}"
echo -e " https://github.com/Ark0N/Codeman"
echo ""
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi; then
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex"
echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity"
echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi"
echo ""
fi
# Security notice — last informational block so it stays visible (when not
# auto-launching below; if we exec, the server prints the same notice anyway).
print_security_notice
# Run now in foreground (must be last — exec replaces the shell)
if [[ "$launch_choice" == "1" ]]; then
local profile
profile=$(detect_shell_profile)
echo -e " ${GREEN}${BOLD}Starting Codeman...${NC}"
echo -e " ${DIM}Press Ctrl+C to stop${NC}"
echo ""
# Source profile to pick up PATH changes, then exec codeman
# shellcheck disable=SC1090
source "$profile" 2>/dev/null || true
if [[ -n "$BIND_HOST" ]]; then
export CODEMAN_HOST="$BIND_HOST"
[[ -n "$BIND_PASSWORD" ]] && export CODEMAN_PASSWORD="$BIND_PASSWORD"
[[ "$BIND_ACK" == "1" ]] && export CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1
fi
exec node "$INSTALL_DIR/dist/index.js" web
fi
}
update() {
if [[ ! -d "$INSTALL_DIR/.git" ]]; then
die "Codeman is not installed at $INSTALL_DIR. Run the installer first."
fi
info "Updating Codeman..."
cd "$INSTALL_DIR"
git remote set-url origin "$REPO_URL" 2>/dev/null || true
# Never blow away local changes silently (this used to be an unconditional
# reset --hard). Interactive users get a choice; headless runs auto-stash
# so the changes stay recoverable, the same policy as scripts/self-update.sh.
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
warn "Local changes detected in $INSTALL_DIR"
if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then
git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)"
info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)"
else
info "Keeping local changes; update skipped."
return 0
fi
fi
git fetch --quiet origin
git reset --hard "origin/$BRANCH" --quiet
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
npm run build --quiet 2>/dev/null || npm run build
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
# Auto-restart service if running, otherwise tell the user
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service 2>/dev/null || true
if verify_systemd_active; then
success "codeman-web service restarted"
else
warn "codeman-web.service did not come back up."
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
fi
elif [[ -f "$agent_plist" ]]; then
info "Restarting LaunchAgent..."
launchctl unload "$agent_plist" 2>/dev/null || true
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent restarted"
else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
fi
echo ""
# Reflect the service's actual binding in the closing notice. Updates
# never rewrite the service files, so the existing choice is authoritative.
read_existing_binding
if [[ "$EXISTING_FOUND" == "1" ]]; then
BIND_HOST="$EXISTING_HOST"
BIND_PASSWORD="$EXISTING_PASSWORD"
BIND_ACK="$EXISTING_ACK"
fi
print_security_notice
}
uninstall() {
print_banner
info "Uninstalling Codeman..."
echo ""
# Stop and remove systemd services (Linux)
for svc in codeman-web codeman-tunnel; do
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
info "Stopping ${svc} service..."
systemctl --user stop "${svc}.service"
fi
if systemctl --user is-enabled "${svc}.service" &>/dev/null 2>&1; then
info "Disabling ${svc} service..."
systemctl --user disable "${svc}.service" 2>/dev/null || true
fi
local svc_file="$HOME/.config/systemd/user/${svc}.service"
if [[ -f "$svc_file" ]]; then
rm -f "$svc_file"
success "Removed ${svc} service"
fi
done
systemctl --user daemon-reload 2>/dev/null || true
# Stop and remove launchd services (macOS)
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
if [[ -f "$agent_plist" ]]; then
launchctl unload "$agent_plist" 2>/dev/null || true
rm -f "$agent_plist"
success "Removed LaunchAgent"
fi
if [[ -f "$daemon_plist" ]]; then
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist"
success "Removed LaunchDaemon"
fi
# Remove OUR tailscale serve mapping (443 -> Codeman's port) only. Other
# serve config stays untouched, and never `tailscale serve reset`.
local ts_url=""
ts_url=$(detect_tailscale_serve_url 2>/dev/null) || ts_url=""
if [[ -n "$ts_url" ]]; then
if prompt_yes_no "Remove the Tailscale serve mapping for Codeman ($ts_url)?" "y"; then
if ts_cmd_serve serve --https=443 off 2>/dev/null; then
success "Removed tailscale serve mapping"
else
warn "Could not remove it automatically. Run: tailscale serve --https=443 off"
fi
fi
fi
# Remove symlinks
local symlink_dir="$HOME/.local/bin"
if [[ -L "$symlink_dir/codeman" ]]; then
rm -f "$symlink_dir/codeman"
success "Removed symlink: $symlink_dir/codeman"
fi
if [[ -L "$symlink_dir/tmux-chooser" ]]; then
rm -f "$symlink_dir/tmux-chooser"
success "Removed symlink: $symlink_dir/tmux-chooser"
fi
remove_sc_alias
# Remove install directory
if [[ -d "$INSTALL_DIR" ]]; then
if prompt_yes_no "Remove installation directory ($INSTALL_DIR)?"; then
rm -rf "$INSTALL_DIR"
success "Removed $INSTALL_DIR"
else
# Clear the marker so a future installer run does full setup again
# (the symlinks and services being removed here need recreating).
rm -f "$INSTALL_DIR/.install-complete"
info "Kept $INSTALL_DIR"
fi
fi
# Ask about data directory
local data_dir="$HOME/.codeman"
if [[ -d "$data_dir" ]]; then
warn "Data directory exists at $data_dir (contains sessions, settings, state)"
if prompt_yes_no "Remove data directory ($data_dir)?" "n"; then
rm -rf "$data_dir"
success "Removed $data_dir"
else
info "Kept $data_dir"
fi
fi
echo ""
success "Codeman uninstalled."
echo ""
echo -e " ${DIM}Note: Shell profile entries (PATH, sc alias) were not removed.${NC}"
echo -e " ${DIM}You can remove them manually from $(detect_shell_profile)${NC}"
echo ""
}
# Wrap in main to prevent partial execution on curl | bash
case "${1:-}" in
update) update ;;
uninstall) uninstall ;;
tailscale) setup_tailscale_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.
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then
print_banner
update
else
main "$@"
fi
;;
esac