#!/bin/bash set -e # spritesync installer # Installs Syncthing, configures it for Tailscale-only access, and sets up services REPO="kylemclaren/spritesync" INSTALL_DIR="/usr/local/bin" SYNCTHING_CONFIG_DIR="$HOME/.config/syncthing" TS_HOSTNAME="${SPRITESYNC_HOSTNAME:-}" # Colors and formatting RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # Status indicators CHECK="${GREEN}+${NC}" CROSS="${RED}x${NC}" ARROW="${BLUE}->${NC}" info() { echo -e " ${CHECK} $1"; } warn() { echo -e " ${YELLOW}!${NC} $1"; } error() { echo -e " ${CROSS} $1"; exit 1; } step() { echo -e "\n${BOLD}$1${NC}"; } substep() { echo -e " ${ARROW} $1"; } # Generate random hostname generate_hostname() { echo "sprite-$(tr -dc 'a-z0-9' < /dev/urandom | head -c 6)" } # Prompt for hostname or generate one prompt_hostname() { # Skip if already set via env var [ -n "$TS_HOSTNAME" ] && return 0 # Skip if already authenticated with a unique hostname if tailscale status > /dev/null 2>&1; then local current_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // empty' 2>/dev/null) # If current hostname looks unique (contains random suffix), keep it if [[ "$current_name" =~ ^sprite-[a-z0-9]{6}$ ]]; then return 0 fi fi # Prompt if running interactively if [ -t 0 ]; then echo "" echo -e " ${DIM}Enter a unique hostname for this device in your tailnet${NC}" echo -e " ${DIM}(e.g., sprite-api, sprite-dev, sprite-prod)${NC}" echo "" read -p " Hostname [Enter to auto-generate]: " input_hostname if [ -n "$input_hostname" ]; then TS_HOSTNAME="$input_hostname" else TS_HOSTNAME=$(generate_hostname) fi else # Non-interactive: generate random hostname TS_HOSTNAME=$(generate_hostname) fi info "Device will be named: ${BOLD}${TS_HOSTNAME}${NC}" } # Detect OS and architecture detect_platform() { OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) error "Unsupported architecture: $ARCH" ;; esac case "$OS" in linux|darwin) ;; *) error "Unsupported OS: $OS" ;; esac PLATFORM="${OS}-${ARCH}" } # Check if running in Sprite environment is_sprite() { [ -S "/.sprite/api.sock" ] } # Install dependencies install_deps() { if ! command -v jq &> /dev/null; then substep "Installing jq..." if command -v apt-get &> /dev/null; then sudo apt-get update -qq > /dev/null 2>&1 sudo apt-get install -y -qq jq > /dev/null 2>&1 elif command -v yum &> /dev/null; then sudo yum install -y -q jq > /dev/null 2>&1 elif command -v brew &> /dev/null; then brew install jq > /dev/null 2>&1 fi fi if ! command -v curl &> /dev/null; then substep "Installing curl..." if command -v apt-get &> /dev/null; then sudo apt-get install -y -qq curl > /dev/null 2>&1 fi fi if ! command -v xmllint &> /dev/null; then substep "Installing xmllint..." if command -v apt-get &> /dev/null; then sudo apt-get install -y -qq libxml2-utils > /dev/null 2>&1 fi fi } # Install Tailscale if not present install_tailscale() { if command -v tailscale &> /dev/null; then info "Tailscale $(tailscale version | head -1) installed" return 0 fi substep "Installing Tailscale..." curl -fsSL https://tailscale.com/install.sh 2>/dev/null | sh > /dev/null 2>&1 info "Tailscale installed" } # Start tailscaled daemon start_tailscaled() { if pgrep -x tailscaled > /dev/null; then info "tailscaled running" return 0 fi if is_sprite; then substep "Creating tailscaled service..." sprite-env services delete tailscaled > /dev/null 2>&1 || true sleep 1 sprite-env services create tailscaled \ --cmd /usr/sbin/tailscaled \ --args "--state=/var/lib/tailscale/tailscaled.state,--socket=/var/run/tailscale/tailscaled.sock" \ --no-stream > /dev/null 2>&1 # Wait for tailscaled for i in {1..10}; do if pgrep -x tailscaled > /dev/null; then break; fi sleep 1 done info "tailscaled service created" elif command -v systemctl &> /dev/null; then substep "Starting tailscaled..." sudo systemctl enable --now tailscaled > /dev/null 2>&1 info "tailscaled started" else substep "Starting tailscaled..." sudo tailscaled --state=/var/lib/tailscale/tailscaled.state > /dev/null 2>&1 & sleep 2 info "tailscaled started" fi } # Authenticate Tailscale auth_tailscale() { # Prompt for hostname first (will skip if already set or already unique) prompt_hostname if tailscale status > /dev/null 2>&1; then local current_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // empty' 2>/dev/null) # Update hostname if we have a new one set if [ -n "$TS_HOSTNAME" ] && [ "$current_name" != "$TS_HOSTNAME" ]; then substep "Updating hostname to $TS_HOSTNAME..." sudo tailscale set --hostname="$TS_HOSTNAME" > /dev/null 2>&1 || true current_name="$TS_HOSTNAME" fi info "Tailscale authenticated as ${BOLD}${current_name:-$(hostname)}${NC}" return 0 fi echo "" echo -e " ${YELLOW}>>${NC} ${BOLD}Authenticate in your browser${NC}" echo "" # Include hostname in tailscale up if set if [ -n "$TS_HOSTNAME" ]; then sudo tailscale up --hostname="$TS_HOSTNAME" 2>&1 | grep -E "https://|Success" || true else sudo tailscale up 2>&1 | grep -E "https://|Success" || true fi local final_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // empty' 2>/dev/null) echo "" info "Authenticated as ${BOLD}${final_name:-$(hostname)}${NC}" } # Set Tailscale operator set_operator() { sudo tailscale set --operator="$USER" > /dev/null 2>&1 || true } # Get Tailscale IP get_tailscale_ip() { tailscale ip -4 2>/dev/null || error "Failed to get Tailscale IP. Is Tailscale authenticated?" } # Install Syncthing install_syncthing() { if command -v syncthing &> /dev/null; then info "Syncthing $(syncthing --version | head -1 | awk '{print $2}') installed" return 0 fi substep "Installing Syncthing..." if [ "$OS" = "linux" ]; then # Add Syncthing apt repository if command -v apt-get &> /dev/null; then # Add the release PGP keys sudo mkdir -p /etc/apt/keyrings curl -fsSL https://syncthing.net/release-key.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/syncthing-archive-keyring.gpg 2>/dev/null # Add the stable channel repository echo "deb [signed-by=/etc/apt/keyrings/syncthing-archive-keyring.gpg] https://apt.syncthing.net/ syncthing stable" | \ sudo tee /etc/apt/sources.list.d/syncthing.list > /dev/null sudo apt-get update -qq > /dev/null 2>&1 sudo apt-get install -y -qq syncthing > /dev/null 2>&1 info "Syncthing installed via apt" else error "apt not available. Please install Syncthing manually." fi elif [ "$OS" = "darwin" ]; then if command -v brew &> /dev/null; then brew install syncthing > /dev/null 2>&1 info "Syncthing installed via brew" else error "Homebrew not available. Please install Syncthing manually." fi fi } # Generate initial Syncthing config if needed init_syncthing_config() { if [ -f "$SYNCTHING_CONFIG_DIR/config.xml" ]; then return 0 fi substep "Generating initial Syncthing config..." syncthing generate --config="$SYNCTHING_CONFIG_DIR" > /dev/null 2>&1 info "Generated Syncthing config" } # Configure Syncthing for Tailscale-only access configure_syncthing() { local ts_ip=$(get_tailscale_ip) local config_file="$SYNCTHING_CONFIG_DIR/config.xml" [ -f "$config_file" ] || error "Syncthing config not found at $config_file" substep "Configuring Syncthing for Tailscale..." # Backup original config cp "$config_file" "${config_file}.bak" # Configure using syncthing CLI if available, otherwise use xmllint if syncthing cli config options listen-addresses set "tcp://${ts_ip}:22000" > /dev/null 2>&1; then info "Set listen address to tcp://${ts_ip}:22000" else # Fallback to xmllint editing local tmp_config=$(mktemp) # Update listenAddress to only Tailscale IP xmllint --shell "$config_file" > /dev/null 2>&1 << EOF || true cd //configuration/options/listenAddress set tcp://${ts_ip}:22000 save $tmp_config EOF if [ -s "$tmp_config" ]; then mv "$tmp_config" "$config_file" info "Set listen address to tcp://${ts_ip}:22000" else rm -f "$tmp_config" # Manual sed fallback sed -i "s|.*|tcp://${ts_ip}:22000|g" "$config_file" info "Set listen address to tcp://${ts_ip}:22000 (sed)" fi fi # Disable global discovery if syncthing cli config options global-announce-enabled set false > /dev/null 2>&1; then info "Disabled global discovery" else sed -i 's|true|false|g' "$config_file" info "Disabled global discovery (sed)" fi # Disable local discovery if syncthing cli config options local-announce-enabled set false > /dev/null 2>&1; then info "Disabled local discovery" else sed -i 's|true|false|g' "$config_file" info "Disabled local discovery (sed)" fi # Disable relays if syncthing cli config options relays-enabled set false > /dev/null 2>&1; then info "Disabled relays" else sed -i 's|true|false|g' "$config_file" info "Disabled relays (sed)" fi # Disable NAT traversal (not needed over Tailscale) if syncthing cli config options natenabled set false > /dev/null 2>&1; then info "Disabled NAT traversal" else sed -i 's|true|false|g' "$config_file" info "Disabled NAT traversal (sed)" fi # Keep GUI on localhost only if syncthing cli config gui raw-address set "127.0.0.1:8384" > /dev/null 2>&1; then info "GUI bound to 127.0.0.1:8384" else info "GUI already on localhost" fi } # Check if sprite-env has stop/start commands sprite_env_has_stop() { sprite-env services --help 2>&1 | grep -q "stop " } # Set up Syncthing as a service setup_syncthing_service() { if is_sprite; then substep "Creating syncthing service..." local syncthing_path=$(which syncthing) if [ -z "$syncthing_path" ]; then error "syncthing not found in PATH" fi # Check if service already exists (any state) if sprite-env services list 2>/dev/null | jq -e '.[] | select(.name == "syncthing")' > /dev/null 2>&1; then if sprite_env_has_stop; then # New sprite-env: use stop/start sprite-env services stop syncthing > /dev/null 2>&1 || true sleep 1 sprite-env services start syncthing > /dev/null 2>&1 || true sleep 2 if sprite-env services list 2>/dev/null | jq -e '.[] | select(.name == "syncthing" and .state.status == "running")' > /dev/null 2>&1; then info "syncthing service restarted" return 0 fi else # Old sprite-env: use signal + delete/create sprite-env services signal syncthing TERM > /dev/null 2>&1 || true sleep 2 fi # Delete and recreate sprite-env services delete syncthing > /dev/null 2>&1 || true sleep 2 fi # Create the service (without --needs for compatibility, tailscaled should already be running) sprite-env services create syncthing \ --cmd "$syncthing_path" \ --args "serve,--no-browser,--no-default-folder,--config=$SYNCTHING_CONFIG_DIR,--data=$SYNCTHING_CONFIG_DIR" \ --no-stream > /dev/null 2>&1 || true # Verify service is actually running sleep 3 if ! sprite-env services list 2>/dev/null | jq -e '.[] | select(.name == "syncthing" and .state.status == "running")' > /dev/null 2>&1; then error "syncthing service failed to start - check: sprite-env services list" fi info "syncthing service running" elif command -v systemctl &> /dev/null; then substep "Creating systemd service..." sudo tee /etc/systemd/system/syncthing@.service > /dev/null < /dev/null 2>&1 sudo systemctl enable --now "syncthing@${USER}" > /dev/null 2>&1 info "systemd service running" else warn "No service manager - run manually:" echo " syncthing serve --no-browser" fi } # Set up spritesync discovery service setup_spritesync_service() { if is_sprite; then substep "Creating spritesync service..." if [ ! -x "$INSTALL_DIR/spritesync" ]; then warn "spritesync binary not found at $INSTALL_DIR/spritesync - skipping service" return 0 fi # Check if service already exists (any state) if sprite-env services list 2>/dev/null | jq -e '.[] | select(.name == "spritesync")' > /dev/null 2>&1; then if sprite_env_has_stop; then # New sprite-env: use stop/start sprite-env services stop spritesync > /dev/null 2>&1 || true sleep 1 sprite-env services start spritesync > /dev/null 2>&1 || true sleep 2 if sprite-env services list 2>/dev/null | jq -e '.[] | select(.name == "spritesync" and .state.status == "running")' > /dev/null 2>&1; then info "spritesync discovery service restarted" return 0 fi else # Old sprite-env: use signal + delete/create sprite-env services signal spritesync TERM > /dev/null 2>&1 || true sleep 2 fi # Delete and recreate sprite-env services delete spritesync > /dev/null 2>&1 || true sleep 2 fi # Create the service (without --needs for compatibility, syncthing should already be running) sprite-env services create spritesync \ --cmd "$INSTALL_DIR/spritesync" \ --args "serve" \ --no-stream > /dev/null 2>&1 || true # Verify service is actually running sleep 3 if ! sprite-env services list 2>/dev/null | jq -e '.[] | select(.name == "spritesync" and .state.status == "running")' > /dev/null 2>&1; then error "spritesync service failed to start - check: sprite-env services list" fi info "spritesync discovery service running" elif command -v systemctl &> /dev/null; then substep "Creating spritesync systemd service..." sudo tee /etc/systemd/system/spritesync@.service > /dev/null < /dev/null 2>&1 sudo systemctl enable --now "spritesync@${USER}" > /dev/null 2>&1 info "spritesync systemd service running" else warn "No service manager - run manually:" echo " spritesync serve" fi } # Get latest release version get_latest_version() { curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | \ grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' } # Download and install spritesync binary install_spritesync() { VERSION=$(get_latest_version) if [ -z "$VERSION" ]; then warn "No releases found - skipping binary install" warn "Build from source: go build -o spritesync" return 0 fi substep "Downloading spritesync ${VERSION}..." curl -fsSL "https://github.com/${REPO}/releases/download/${VERSION}/spritesync-${PLATFORM}" \ -o /tmp/spritesync 2>/dev/null || { warn "Failed to download binary - skipping" return 0 } chmod +x /tmp/spritesync sudo mv /tmp/spritesync "$INSTALL_DIR/spritesync" info "Installed spritesync ${VERSION}" } # Install Claude Code skill install_skill() { # Create skills directory if it doesn't exist local skill_dir="$HOME/.claude/skills/spritesync" mkdir -p "$skill_dir" cat > "$skill_dir/SKILL.md" << 'SKILL_EOF' --- name: spritesync description: Use this skill when users want to sync directories between Sprite VMs using Syncthing over Tailscale. Use for syncing folders, checking sync status, discovering devices, or managing shared directories. --- You help manage Syncthing-based directory synchronization between Sprite VMs over Tailscale. ## Mesh Sync (Recommended) Create named sync groups that any device can join: ```bash # On first Sprite - create the group spritesync create myproject ~/code/myproject # On every other Sprite - just join spritesync join myproject ``` All devices in a group sync in a mesh (no single point of failure). ## Commands ### Mesh Sync ```bash spritesync create # Create a named sync group spritesync join # Join an existing sync group spritesync groups # List sync groups on this device ``` ### Direct Sync (one-off between two devices) ```bash spritesync sync ~/myproject # Sync with a specific device spritesync unsync ~/myproject # Stop syncing a directory ``` ### Status & Discovery ```bash spritesync status # Show sync status of all folders spritesync devices # Discover spritesync devices on the tailnet spritesync info # Show this device's Syncthing ID and hostname ``` ## Quick Actions When user asks to: - "create a sync" / "new sync group" -> `spritesync create ` - "join sync" / "join group" -> `spritesync join ` - "list groups" / "what groups" -> `spritesync groups` - "sync status" / "what's syncing" -> `spritesync status` - "find devices" / "list devices" -> `spritesync devices` - "stop syncing" -> `spritesync unsync ` ## How It Works 1. `create` makes a named sync group and registers it with the discovery service 2. `join` finds the group on the tailnet and connects to ALL existing members 3. All devices sync in a mesh - any device can go down without breaking sync 4. Tailnet membership = trust (no manual device approval needed) ## Troubleshooting Check services: ```bash sprite-env services get syncthing # Syncthing service sprite-env services get spritesync # Discovery service ``` Verify Tailscale connection: ```bash tailscale status tailscale ping ``` SKILL_EOF info "Claude Code skill installed" } # Main main() { echo "" echo -e "${BOLD} spritesync${NC} ${DIM}installer${NC}" echo "" step "Setting up environment" detect_platform info "Platform: ${PLATFORM}" install_deps step "Installing Tailscale" install_tailscale start_tailscaled step "Configuring Tailscale" auth_tailscale set_operator step "Installing Syncthing" install_syncthing init_syncthing_config configure_syncthing setup_syncthing_service step "Installing spritesync" install_spritesync setup_spritesync_service install_skill # Get device info for display local device_name=$(tailscale status --json 2>/dev/null | jq -r '.Self.HostName // empty' 2>/dev/null) device_name="${device_name:-$(hostname)}" local ts_ip=$(get_tailscale_ip) echo "" echo -e "${BOLD} Done!${NC}" echo "" echo -e " ${DIM}Device:${NC} ${BOLD}${device_name}${NC}" echo -e " ${DIM}Tailscale:${NC} ${ts_ip}" echo -e " ${DIM}Syncthing:${NC} ${ts_ip}:22000" echo -e " ${DIM}Discovery:${NC} ${ts_ip}:8385" echo "" echo -e " ${DIM}Quick Start:${NC}" echo -e " ${BOLD}spritesync create skills ~/.claude/skills${NC} # Create a sync group" echo -e " ${BOLD}spritesync join skills${NC} # Join from other devices" echo "" echo -e " ${DIM}Other Commands:${NC}" echo -e " ${BOLD}spritesync devices${NC} # Find other spritesync devices" echo -e " ${BOLD}spritesync status${NC} # Check sync status" echo -e " ${BOLD}spritesync groups${NC} # List sync groups" echo "" } main "$@"