#!/bin/bash # sync-to-vm.sh — Copy local config to an opencode-devbox VM # # Reads docker-compose.yml on the remote VM to detect which bind mounts # are active, then syncs the corresponding directories from this machine. # # Usage: # ./sync-to-vm.sh # # Examples: # ./sync-to-vm.sh devbox-affection # ./sync-to-vm.sh devbox@129.192.68.184 set -euo pipefail # ── Colors ────────────────────────────────────────────────────────── BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m" info() { echo -e "${BOLD}==>${RESET} $*"; } ok() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; } warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; } err() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; } # ── Args ──────────────────────────────────────────────────────────── if [[ $# -lt 1 ]]; then err "Usage: $0 " echo " Example: $0 devbox-affection" exit 1 fi SSH_HOST="$1" REMOTE_COMPOSE="~/opencode-devbox/docker-compose.yml" # ── SSH multiplexing (reuse one connection for all operations) ────── CTRL_SOCKET=$(mktemp -u /tmp/sync-to-vm-XXXXXX) SSH_OPTS="-o ControlMaster=auto -o ControlPath=${CTRL_SOCKET} -o ControlPersist=120 -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3" cleanup() { ssh ${SSH_OPTS} -O exit "$SSH_HOST" 2>/dev/null || true rm -f "$CTRL_SOCKET" } trap cleanup EXIT ssh_cmd() { ssh ${SSH_OPTS} "$SSH_HOST" "$@" } # ── Bind mount patterns to detect ────────────────────────────────── # Maps: grep pattern → local source → remote destination declare -a MOUNT_PATTERNS=( "~/.aws:/home/developer/.aws|$HOME/.aws|~/.aws" "~/.config/opencode:/home/developer/.config/opencode|$HOME/.config/opencode|~/.config/opencode" "~/.config/nvim:/home/developer/.config/nvim|$HOME/.config/nvim|~/.config/nvim" "~/.agents/skills:/home/developer/.agents/skills|$HOME/.agents/skills|~/.agents/skills" ) # ── Establish persistent SSH connection ───────────────────────────── info "Connecting to ${SSH_HOST}..." if ! ssh_cmd true 2>/dev/null; then err "Cannot connect to ${SSH_HOST}" exit 1 fi ok "Connected to ${SSH_HOST}" # ── Fetch remote docker-compose.yml ───────────────────────────────── info "Reading docker-compose.yml from ${SSH_HOST}..." REMOTE_COMPOSE_CONTENT=$(ssh_cmd "cat $REMOTE_COMPOSE 2>/dev/null") || { err "Could not read ${REMOTE_COMPOSE} on ${SSH_HOST}" err "Has the VM been set up? Run the post-setup steps first." exit 1 } # ── Ensure workspace directory exists on remote ───────────────────── REMOTE_ENV="~/opencode-devbox/.env" WORKSPACE_PATH=$(ssh_cmd "grep -E '^\s*WORKSPACE_PATH=' $REMOTE_ENV 2>/dev/null | cut -d= -f2- | tr -d '\"'" 2>/dev/null || true) if [[ -n "$WORKSPACE_PATH" ]]; then info "Ensuring WORKSPACE_PATH (${WORKSPACE_PATH}) exists on ${SSH_HOST}..." ssh_cmd "mkdir -p ${WORKSPACE_PATH}" ok "Workspace directory ready" else # Default from docker-compose.yml is ~/projects or current dir WORKSPACE_PATH=$(echo "$REMOTE_COMPOSE_CONTENT" | grep -oP 'WORKSPACE_PATH:-[^}]+' | sed 's/WORKSPACE_PATH:-//' || true) if [[ -n "$WORKSPACE_PATH" && "$WORKSPACE_PATH" != "." ]]; then info "Ensuring default WORKSPACE_PATH (${WORKSPACE_PATH}) exists on ${SSH_HOST}..." ssh_cmd "mkdir -p ${WORKSPACE_PATH}" ok "Workspace directory ready" fi fi # ── Detect active bind mounts ────────────────────────────────────── SYNCED=0 for entry in "${MOUNT_PATTERNS[@]}"; do IFS='|' read -r pattern local_path remote_path <<< "$entry" # Check if the mount is uncommented (active) in docker-compose.yml # Match lines that start with optional whitespace and a dash, NOT preceded by # if echo "$REMOTE_COMPOSE_CONTENT" | grep -qE "^\s*-\s+['\"]?${pattern}" 2>/dev/null; then # Mount is active — check if local source exists if [[ ! -d "$local_path" ]]; then warn "Mount active for ${pattern} but ${local_path} does not exist locally — skipping" continue fi # Check if directory has content if [[ -z "$(ls -A "$local_path" 2>/dev/null)" ]]; then warn "${local_path} is empty — skipping" continue fi info "Syncing ${local_path} → ${SSH_HOST}:${remote_path}" # Ensure remote directory exists ssh_cmd "mkdir -p ${remote_path}" # Sync with rsync (fall back to scp if rsync unavailable) # Exclude generated/cached content that gets recreated on the remote. # Use -rlptD (archive minus -o -g) so ownership on the remote is set # by the receiving user (devbox). Preserving host UID/GID with -a # tagged files with the pusher's numeric GID, which leaked through # whenever the VM happened to have a matching group (see #group-1001). if command -v rsync &>/dev/null; then rsync -rlptDz --progress \ --exclude='node_modules' \ --exclude='__pycache__' \ --exclude='.venv' \ --exclude='*.pyc' \ --exclude='cli/cache' \ --exclude='sso/cache' \ -e "ssh ${SSH_OPTS}" "${local_path}/" "${SSH_HOST}:${remote_path}/" else scp -o "ControlPath=${CTRL_SOCKET}" -r "${local_path}/." "${SSH_HOST}:${remote_path}/" fi ok "Synced ${local_path}" SYNCED=$((SYNCED + 1)) fi done # ── Summary ───────────────────────────────────────────────────────── echo "" if [[ $SYNCED -eq 0 ]]; then warn "No active bind mounts detected in remote docker-compose.yml" warn "Uncomment the mounts you need in ${REMOTE_COMPOSE} on the VM, then re-run this script" else ok "Synced ${SYNCED} director$([ $SYNCED -eq 1 ] && echo 'y' || echo 'ies') to ${SSH_HOST}" fi