Files
Codeman/install.sh
T
2026-03-14 16:52:28 +09:00

1380 lines
40 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 (for CI/automation)
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd 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)
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}"
# 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"
)
# ============================================================================
# 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
}
# ============================================================================
# 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_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
if ! sudo -v 2>/dev/null; then
die "Failed to obtain sudo privileges."
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..."
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 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
# ============================================================================
prompt_yes_no() {
local prompt="$1"
local default="${2:-y}"
if [[ "$NONINTERACTIVE" == "1" ]]; then
[[ "$default" == "y" ]]
return
fi
# Check if stdin is a terminal
if [[ ! -t 0 ]]; 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 -r answer
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"
}
# ============================================================================
# Systemd Service Setup (Linux only)
# ============================================================================
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)
# 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
[Install]
WantedBy=default.target
EOF
# Reload systemd
systemctl --user daemon-reload
# 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
# Start the service immediately
systemctl --user start codeman-web.service 2>/dev/null || true
success "Systemd service installed and started"
}
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
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
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
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 (at least one required: Claude Code or OpenCode)
local has_claude=false
local has_opencode=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 [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman requires at least one: Claude Code or OpenCode."
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 ""
local cli_choice=""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
# Non-interactive: default to Claude Code
cli_choice="1"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read -r cli_choice
case "$cli_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&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 [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "At least one AI CLI is required. Install 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
# ========================================================================
# Launch Options
# ========================================================================
echo ""
echo -e "${GREEN}${BOLD}============================================================${NC}"
echo -e "${GREEN}${BOLD} Codeman installed successfully!${NC}"
echo -e "${GREEN}${BOLD}============================================================${NC}"
echo ""
local launch_choice=""
local has_systemd=false
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
has_systemd=true
fi
if [[ "$has_systemd" == "true" ]]; then
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 systemd service (auto-start on boot)"
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
launch_choice="3"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read -r launch_choice
case "$launch_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
esac
done
fi
else
# macOS or no systemd — 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" ]] || [[ ! -t 0 ]]; then
launch_choice="2"
else
while true; do
echo -en "${CYAN}Choose [1/2]:${NC} " >&2
read -r launch_choice
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 systemd setup
if [[ "$launch_choice" == "2" ]]; then
setup_systemd_service
# Offer tunnel service if cloudflared is available
if 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 ""
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
echo ""
echo -e " ${BOLD}Manage the service:${NC}"
echo ""
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"
echo ""
fi
# Show quick-start help for non-service paths
if [[ "$launch_choice" != "2" ]]; then
echo -e " ${BOLD}Quick Start:${NC}"
echo ""
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"
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; 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 ""
fi
# 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
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 fetch --quiet origin
git reset --hard origin/master --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
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
# Auto-restart systemd service if it's running, otherwise tell the user
if systemctl --user is-active codeman-web.service &>/dev/null; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service 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 ""
}
uninstall() {
print_banner
info "Uninstalling Codeman..."
echo ""
# Stop and remove systemd services
for svc in codeman-web codeman-tunnel; do
if systemctl --user is-active "${svc}.service" &>/dev/null; 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
# 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
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 ;;
*)
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
print_banner
update
else
main "$@"
fi
;;
esac