#!/usr/bin/env bash
set -euo pipefail
# pi-web installer — downloads the binary and sets up auto-start
#
# Standalone (no pi required):
# curl -fsSL https://raw.githubusercontent.com/ygncode/pi-web/main/install.sh | bash
#
# Via pi package (also registers /remote, /refresh commands):
# pi install npm:@ygncode/pi-web@beta
#
# Updates are handled by re-running the same command.
REPO="ygncode/pi-web"
if [[ -n "${PI_WEB_INSTALL_DIR:-}" ]]; then
INSTALL_DIR="$PI_WEB_INSTALL_DIR"
elif [[ -n "${npm_package_name:-}" ]]; then
# pi installs npm packages non-interactively; avoid requiring sudo during npm postinstall.
INSTALL_DIR="${HOME}/.pi/agent/bin"
else
INSTALL_DIR="/usr/local/bin"
fi
BINARY="$INSTALL_DIR/pi-web"
SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
VERSION_FILE="${HOME}/.pi/agent/pi-web-version"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}→${NC} $*" >&2; }
warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
err() { echo -e "${RED}✗${NC} $*" >&2; }
# ── Detect platform ─────────────────────────────────────────────────
detect_platform() {
local os arch
case "$(uname -s)" in
Darwin) os="darwin" ;;
Linux) os="linux" ;;
*)
err "Unsupported OS: $(uname -s)"
exit 1
;;
esac
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
*)
err "Unsupported architecture: $(uname -m)"
exit 1
;;
esac
echo "${os}-${arch}"
}
# ── Choose release tag ──────────────────────────────────────────────
package_tag() {
# When install.sh runs as an npm lifecycle script, install the binary that
# matches the npm package version. This keeps pinned installs such as
# `pi install npm:@ygncode/pi-web@0.0.1-beta.25` pinned for both the extension
# package and the downloaded pi-web binary.
if [[ "${npm_package_name:-}" == "@ygncode/pi-web" && -n "${npm_package_version:-}" ]]; then
echo "v${npm_package_version#v}"
fi
}
# ── Check latest release tag ────────────────────────────────────────
latest_tag() {
local latest_url="https://api.github.com/repos/${REPO}/releases/latest"
local releases_url="https://api.github.com/repos/${REPO}/releases?per_page=100"
local tag=""
if command -v curl &>/dev/null; then
tag="$(curl -fsS "$latest_url" 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/' || true)"
if [[ -z "$tag" ]]; then
# /latest ignores prereleases. Fall back to the highest semver release of any type.
tag="$(curl -fsS "$releases_url" 2>/dev/null | grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/' | sort -V | tail -1 || true)"
fi
elif command -v wget &>/dev/null; then
tag="$(wget -qO- "$latest_url" 2>/dev/null | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/' || true)"
if [[ -z "$tag" ]]; then
# /latest ignores prereleases. Fall back to the highest semver release of any type.
tag="$(wget -qO- "$releases_url" 2>/dev/null | grep '"tag_name"' | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/' | sort -V | tail -1 || true)"
fi
else
err "Neither curl nor wget found."
exit 1
fi
if [[ -z "$tag" ]]; then
err "Could not determine latest release tag from ${REPO}."
exit 1
fi
echo "$tag"
}
# ── Get installed version ───────────────────────────────────────────
installed_version() {
if [[ -x "$BINARY" ]]; then
"$BINARY" -version 2>/dev/null || true
elif [[ -f "$VERSION_FILE" ]]; then
# Binary not executable yet (e.g., partial install); fall back to version file
cat "$VERSION_FILE"
fi
}
# ── Check if update is needed ───────────────────────────────────────
needs_update() {
local latest="$1"
if [[ ! -f "$BINARY" ]]; then
return 0 # not installed yet
fi
local installed
installed="$(installed_version)"
if [[ -n "$installed" ]] && [[ "$installed" == "$latest" ]]; then
return 1 # already up-to-date
fi
if [[ -n "$installed" ]]; then
info "Update available: ${installed} → ${latest}"
else
info "Existing binary found (unknown version). Installing ${latest}."
fi
return 0 # needs update
}
# ── Download binary ─────────────────────────────────────────────────
download_binary() {
local platform="$1"
local tag="$2"
local asset="pi-web-${platform}"
local url="https://github.com/${REPO}/releases/download/${tag}/${asset}"
info "Downloading pi-web ${tag} (${platform})..."
info " ${url}"
local tmp
tmp="$(mktemp -d)"
if command -v curl &>/dev/null; then
curl -fsSL --progress-bar -o "${tmp}/pi-web" "$url"
elif command -v wget &>/dev/null; then
wget -q --show-progress -O "${tmp}/pi-web" "$url"
else
err "Neither curl nor wget found. Install one and try again."
exit 1
fi
chmod +x "${tmp}/pi-web"
echo "${tmp}/pi-web"
}
# ── Install binary ──────────────────────────────────────────────────
install_binary() {
local src="$1"
local tag="$2"
local is_update="${3:-false}"
local inplace="${PI_WEB_INPLACE_UPDATE:-}"
if [[ -f "$BINARY" ]] && [[ "$is_update" != "true" ]]; then
# Interactive: ask before overwriting
warn "pi-web already installed at ${BINARY}"
read -rp " Overwrite? [y/N] " answer
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
info "Skipping binary install."
return 1
fi
fi
# Stop running instance before replacing. Skipped for in-place self-updates:
# pi-web spawned this script (via `pi install`), so stopping the service here
# would kill the very npm process running it. pi-web triggers its own detached
# restart afterward (see internal/app/update.go).
if [[ -f "$BINARY" && -z "$inplace" ]]; then
if [[ "$(uname -s)" == "Linux" ]]; then
systemctl --user stop pi-web.service 2>/dev/null || true
elif [[ "$(uname -s)" == "Darwin" ]]; then
launchctl unload "${HOME}/Library/LaunchAgents/com.pi-web.plist" 2>/dev/null || true
fi
# Also try pkill for manually-started instances
pkill -f "${BINARY}" 2>/dev/null || true
sleep 1
fi
mkdir -p "$INSTALL_DIR"
if [[ ! -w "$INSTALL_DIR" ]]; then
info "Installing to ${INSTALL_DIR} (requires sudo)..."
sudo cp "$src" "$BINARY"
elif [[ -n "$inplace" ]]; then
# Atomic swap so the binary can be replaced while the old process still
# runs — a plain cp over a running executable fails with ETXTBSY on Linux.
# The temp file must share $BINARY's directory so the mv is a pure rename(2).
local staged="${BINARY}.new.$$"
cp "$src" "$staged"
chmod +x "$staged"
mv -f "$staged" "$BINARY"
else
cp "$src" "$BINARY"
fi
# Record version
mkdir -p "$(dirname "$VERSION_FILE")"
echo "$tag" > "$VERSION_FILE"
info "pi-web ${tag} installed to ${BINARY}"
return 0
}
# ── Fetch config file from repo (for standalone installs) ──────────
fetch_config() {
local file="$1"
local dest="$2"
local url="https://raw.githubusercontent.com/${REPO}/main/${file}"
if command -v curl &>/dev/null; then
curl -fsSL -o "$dest" "$url"
else
wget -q -O "$dest" "$url"
fi
}
# ── macOS auto-start ─────────────────────────────────────────────────
setup_macos() {
local plist_dst="${HOME}/Library/LaunchAgents/com.pi-web.plist"
local needs_reload=true
mkdir -p "${HOME}/Library/LaunchAgents"
# Generate plist from local file or fetch from repo
local generated
generated="$(mktemp)"
local plist_src="${SRC_DIR}/init/com.pi-web.plist"
if [[ -f "$plist_src" ]]; then
sed "s|/usr/local/bin/pi-web|${BINARY}|g" "$plist_src" > "$generated"
else
info "Fetching launchd config from repo..."
local raw
raw="$(mktemp)"
fetch_config "init/com.pi-web.plist" "$raw"
sed "s|/usr/local/bin/pi-web|${BINARY}|g" "$raw" > "$generated"
rm -f "$raw"
fi
info "pi-web will listen on localhost; if Tailscale is running, it will publish HTTPS with Tailscale Serve."
# Pass the generated environment to launchd. This includes PI_WEB_TOKEN and
# PATH so pi-web can find `pi` when serving browser chat requests.
local env_file="${HOME}/.config/pi-web/env"
if [[ -f "$env_file" ]]; then
local env_xml=""
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" == \#* ]] && continue
case "$key" in
PI_WEB_TOKEN|PI_CODING_AGENT_DIR|PATH) ;;
*) continue ;;
esac
value="$(printf '%s' "$value" | sed 's/&/\&/g; s/\</g; s/>/\>/g; s/"/\"/g')"
env_xml="${env_xml} ${key}\n ${value}\n"
done < "$env_file"
if [[ -n "$env_xml" ]]; then
perl -0pi -e "s|\s*| EnvironmentVariables\n \n${env_xml} \n\n|" "$generated"
fi
fi
# Check if plist changed
if [[ -f "$plist_dst" ]]; then
if cmp -s "$generated" "$plist_dst"; then
info "Auto-start config unchanged."
needs_reload=false
fi
fi
if [[ "$needs_reload" == "true" ]]; then
cp "$generated" "$plist_dst"
launchctl bootout "gui/$(id -u)" "$plist_dst" 2>/dev/null || launchctl unload "$plist_dst" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$plist_dst" 2>/dev/null || launchctl load "$plist_dst"
info "macOS auto-start configured (launchd)"
fi
rm -f "$generated"
# Restart if already running
launchctl kickstart -k "gui/$(id -u)/com.pi-web" 2>/dev/null || {
launchctl stop com.pi-web 2>/dev/null || true
launchctl start com.pi-web 2>/dev/null || true
}
}
# ── Linux auto-start (systemd user service) ──────────────────────────
setup_linux() {
local service_dir="${HOME}/.config/systemd/user"
local service_dst="${service_dir}/pi-web.service"
local needs_reload=true
mkdir -p "$service_dir"
# Get service file from local clone or fetch from repo
local service_src="${SRC_DIR}/init/pi-web.service"
if [[ ! -f "$service_src" ]]; then
info "Fetching systemd service file from repo..."
service_src="$(mktemp)"
fetch_config "init/pi-web.service" "$service_src"
fi
local generated_service
generated_service="$(mktemp)"
sed "s|/usr/local/bin/pi-web|${BINARY}|g" "$service_src" > "$generated_service"
info "pi-web will listen on localhost; if Tailscale is running, it will publish HTTPS with Tailscale Serve."
# Check if service file changed
if [[ -f "$service_dst" ]]; then
if cmp -s "$generated_service" "$service_dst"; then
info "Service config unchanged."
needs_reload=false
fi
fi
if [[ "$needs_reload" == "true" ]]; then
cp "$generated_service" "$service_dst"
systemctl --user daemon-reload 2>/dev/null || {
warn "Could not reload user systemd; skipping auto-start setup."
return 0
}
info "Linux auto-start updated (systemd user service)"
fi
# Enable and restart when user systemd is available.
systemctl --user enable pi-web.service 2>/dev/null || true
systemctl --user restart pi-web.service 2>/dev/null || {
# Service may not be running yet (first install)
systemctl --user start pi-web.service 2>/dev/null || true
}
}
# ── Environment setup ────────────────────────────────────────────────
set_env_var() {
local file="$1"
local key="$2"
local value="$3"
if [[ -f "$file" ]] && grep -q "^${key}=" "$file"; then
local escaped
escaped="$(printf '%s' "$value" | sed 's/[&\\]/\\&/g')"
sed -i.bak "s|^${key}=.*|${key}=${escaped}|" "$file"
rm -f "${file}.bak"
else
printf '%s=%s\n' "$key" "$value" >> "$file"
fi
}
setup_env() {
local env_dir="${HOME}/.config/pi-web"
local env_file="${env_dir}/env"
mkdir -p "$env_dir"
chmod 700 "$env_dir" 2>/dev/null || true
touch "$env_file"
chmod 600 "$env_file" 2>/dev/null || true
if [[ -z "${PI_WEB_TOKEN:-}" ]] && ! grep -q '^PI_WEB_TOKEN=' "$env_file"; then
local token
if command -v openssl &>/dev/null; then
token="$(openssl rand -hex 16)"
else
token="$(date +%s%N)-$RANDOM-$RANDOM"
fi
set_env_var "$env_file" "PI_WEB_TOKEN" "$token"
info "Generated PI_WEB_TOKEN in ${env_file}"
warn "Use this token when opening pi-web from another device: ${token}"
fi
# Persist PI_CODING_AGENT_DIR so auto-started pi-web finds the right sessions.
if [[ -n "${PI_CODING_AGENT_DIR:-}" ]]; then
set_env_var "$env_file" "PI_CODING_AGENT_DIR" "${PI_CODING_AGENT_DIR}"
fi
# Services launched by systemd/launchd often have a minimal PATH. Preserve the
# install-time PATH so pi-web can find `pi` for browser chat (`pi --mode rpc`).
set_env_var "$env_file" "PATH" "${PATH}"
}
# ── Main ────────────────────────────────────────────────────────────
main() {
echo ""
info "pi-web installer"
echo ""
local platform
platform="$(detect_platform)"
local tag
tag="$(package_tag)"
if [[ -n "$tag" ]]; then
info "Using pi-web package version ${tag}."
else
tag="$(latest_tag)"
fi
if ! needs_update "$tag"; then
info "Already up-to-date (${tag})."
echo ""
exit 0
fi
local tmp_binary
tmp_binary="$(download_binary "$platform" "$tag")"
# Check if running interactively
local is_update=false
if [[ ! -t 0 ]]; then
is_update=true # non-interactive → update mode (no prompts)
fi
if ! install_binary "$tmp_binary" "$tag" "$is_update"; then
# User chose not to overwrite
exit 0
fi
# In-place self-update: pi-web triggered this and restarts itself afterward
# via its own /api/restart. Skip env/service setup so we don't restart (and
# kill) the npm process running this script, or clobber the service's PATH.
if [[ -n "${PI_WEB_INPLACE_UPDATE:-}" ]]; then
info "Binary updated to ${tag}; pi-web will restart to apply it."
echo ""
exit 0
fi
setup_env
case "$(uname -s)" in
Darwin) setup_macos ;;
Linux) setup_linux ;;
esac
info "Done! pi-web ${tag} is ready."
echo ""
}
main