Files
Codeman/install.sh
T
Codeman maintainer c5b59633d8 feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206)
SessionMode gains 'pi', a first-class backend alongside Claude Code,
OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose
tab identity, welcome button, run-mode entry, cron agentType, Docker and
remote-SSH command defaults, and clone-repo Brain option.

Pi is a different shape of CLI from the other four, and three decisions
follow from that:

- It has NO permission prompts and no sandbox, so there is no
  --dangerously-skip-permissions analog and none was invented. The
  privilege-shaped knob is the tri-state approveProjectTrust, which makes
  pi load and EXECUTE repo-local .pi/extensions TypeScript and install
  missing project packages. clampExternalCliBypassForOwner() therefore
  puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets
  --no-approve even when no config was sent, because pi's own default is
  a prompt the session user could answer themselves. That helper had zero
  test coverage; it now has coverage for all four CLIs.
- Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars
  share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode
  context, so admitting them would widen the allowlist for every mode at
  once. Auth goes through pi's /login or the server's own environment.
  --api-key is deliberately never wired: it would put a provider secret on
  the spawn command line.
- pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the
  main screen with terminal-owned scrollback, and its 0.84.0 fullscreen
  mode is runtime-switchable via /settings; that flip was measured to put
  the pane into the alt screen, which the strip would have corrupted.

pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires
semver-shaped output, because `pi` is a short generic name a stray binary
can shadow; GET /api/pi/status surfaces path and version so a
misresolution is diagnosable rather than presenting as a broken mode.

Docker installs pi in its own --ignore-scripts step so that flag cannot
affect the other four CLIs, and seeds its credentials per-file rather than
whole-dir (~/.pi/agent also holds sessions, extensions and package trees).

Verified end to end against pi 0.84.1 on an isolated instance: resolver
search-dir fallback, flag construction, piConfig persistence across a full
server restart, the trust prompt and its --no-approve suppression, the
rose Run button on the default daylight-blue skin (the nested skin block
eats per-mode gradients unless the rule lives inside it), and the buffer
local-echo policy, which pi tolerates where codex did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 13:54:47 +02:00

2674 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"
}
setup_sc_alias() {
local profile
profile=$(detect_shell_profile)
# Check if alias already exists
if [[ -f "$profile" ]] && grep -qE "^alias sc=" "$profile" 2>/dev/null; then
info "Alias 'sc' already configured in $profile"
return 0
fi
echo "" >> "$profile"
echo "# Codeman tmux session shortcut" >> "$profile"
echo "alias sc='tmux-chooser'" >> "$profile"
info "Added 'sc' alias for tmux-chooser"
}
# ============================================================================
# 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"
# Install tmux-chooser as 'tmux-chooser' command
if [[ -f "$INSTALL_DIR/scripts/tmux-chooser.sh" ]]; then
ln -sf "$INSTALL_DIR/scripts/tmux-chooser.sh" "$symlink_dir/tmux-chooser"
info "Created symlink: $symlink_dir/tmux-chooser"
# Add 'sc' alias for quick access
setup_sc_alias
fi
# 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}sc${NC} # Interactive tmux session chooser"
echo -e " ${CYAN}sc 2${NC} # Quick attach to session 2"
echo -e " ${CYAN}sc -h${NC} # Help"
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 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