#!/usr/bin/env bash # # push-to-nas.sh — push the working tree to the Synology NAS over SSH and # run the on-NAS deploy, streaming every stage live and # ending with a clear PASS/FAIL result. # # This is the LOCAL-side counterpart to deploy.sh (which runs ON the NAS). # It is what `npm run deploy` invokes. # # What it does (4 stages, each with a banner you can watch): # [0/4] Preflight — verify rsync/ssh exist, the SSH key is present, and a # one-shot SSH connection to the NAS succeeds (BatchMode, # so it fails fast instead of prompting for a password) # [1/4] Test — run the test suite (`npm test` -> vitest run) as a GATE. # Runs BEFORE anything touches the NAS, so a red suite # aborts the deploy fast and locally. CI-safe (vitest run, # not watch). Skip only with SKIP_TESTS=1 (escape hatch). # [2/4] Sync — `rsync` the working tree to $NAS_PATH on the NAS # (delete-extraneous, but excluding build/runtime cruft # and — critically — the NAS-local .env and backups) # [3/4] Deploy — run `scripts/deploy.sh` ON the NAS over SSH, forwarding # any extra args you pass (e.g. --fresh / --pull) # # Why identity is pinned on every SSH hop: # In some environments (notably IDE-spawned shells such as WebStorm's npm # runner) ssh-agent offers MANY identities. The server can hit MaxAuthTries # and reject the connection BEFORE the correct key is tried — "Permission # denied" even though the key is loaded. Passing an explicit `-i $NAS_KEY` # together with `-o IdentitiesOnly=yes` makes ssh offer ONLY that one key, # so auth is deterministic regardless of how many identities the agent has. # This is applied to the preflight ssh, the rsync transport, AND the remote # deploy ssh — every outbound hop. # # Prerequisites: # - rsync and ssh on the local PATH (macOS/Linux ship both). # - An SSH key that can log into the NAS as $NAS_USER (default # ~/.ssh/id_ed25519; override with NAS_KEY). Key-based auth must already # work — this script does not set up keys. # - deploy.sh + docker-compose.yml + .env already present (or about to be # rsynced) under $NAS_PATH on the NAS. NOTE: .env is intentionally NOT # synced (it is NAS-local and gitignored); create it on the NAS once. # # How to run: # ./scripts/push-to-nas.sh # sync + remote deploy # ./scripts/push-to-nas.sh --fresh # extra args pass through to # # deploy.sh on the NAS # ./scripts/push-to-nas.sh --help # show usage # # Or simply: npm run deploy # # Config (override via environment): # NAS_HOST NAS hostname/IP (default: mycloud.dp.ua) # NAS_USER SSH user on the NAS (default: d.tkachenko) # NAS_PORT SSH port (default: 2323) # NAS_PATH Deploy dir on the NAS (default: /volume1/docker/forge) # NAS_KEY SSH private key to authenticate (default: ~/.ssh/id_ed25519) # # Safety: # - rsync excludes .git, node_modules, dist, the local .env, and backups, # so we never clobber NAS-local secrets/data or push local build cruft. # - Read-only locally; the only mutation is on the NAS via deploy.sh, which # is itself non-destructive (no down/prune/volume drops). # - No secrets echoed. # # Exit codes: # 0 push + remote deploy OK # 1 preflight / sync / remote-deploy failure (remote exit code propagated) # 2 usage error (bad flag) / missing prerequisite # set -euo pipefail # --- Resolve repo root (works regardless of CWD; tolerates spaces) -------- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" # Optional per-machine overrides. If .deploy.env exists (gitignored), load it so # its NAS_HOST/NAS_USER/NAS_PORT/NAS_PATH/NAS_KEY win over the baked defaults # below. Not required — the defaults already target this NAS. if [[ -f .deploy.env ]]; then set -a; . ./.deploy.env; set +a; fi # --- Config (baked defaults for this NAS; override via env or .deploy.env) -- NAS_HOST="${NAS_HOST:-mycloud.dp.ua}" NAS_USER="${NAS_USER:-d.tkachenko}" NAS_PORT="${NAS_PORT:-2323}" NAS_PATH="${NAS_PATH:-/volume1/docker/forge}" NAS_KEY="${NAS_KEY:-$HOME/.ssh/id_ed25519}" # --- Flags ---------------------------------------------------------------- # Everything we don't recognise is forwarded verbatim to deploy.sh on the # NAS (so `--fresh` and future deploy.sh flags Just Work). --help is local. DEPLOY_ARGS=() for arg in "$@"; do case "$arg" in --help|-h) sed -n '2,/^set -euo pipefail/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^set -euo pipefail/d' exit 0 ;; *) DEPLOY_ARGS+=("$arg") ;; esac done # --- Colour (only on a TTY; degrade to empty strings head-less) ----------- if [[ -t 1 ]]; then C_RESET=$'\033[0m' C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_BOLD=$'\033[1m' else C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BOLD='' fi ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } banner() { # banner "[1/3]" "Syncing files" printf '\n%s==> %s %s%s\n' "$C_BOLD" "$1" "$2" "$C_RESET" } log() { printf '[push %s] %s\n' "$(ts)" "$*"; } warn() { printf '%s[push %s] WARN: %s%s\n' "$C_YELLOW" "$(ts)" "$*" "$C_RESET" >&2; } # Print a FAILED banner with a stage label, then exit non-zero. fail() { # fail "" "" [] local code="${3:-1}" printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET" printf '%s%s PUSH FAILED at stage: %s%s\n' "$C_BOLD" "$C_RED" "$1" "$C_RESET" printf '%s%s %s%s\n' "$C_BOLD" "$C_RED" "$2" "$C_RESET" printf '%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET" exit "$code" } # --- SSH options shared by every outbound hop ----------------------------- # Pin the exact key + IdentitiesOnly so ssh offers ONLY this identity. This is # the whole point of the script: deterministic auth regardless of how many keys # ssh-agent holds (the WebStorm-vs-Terminal discrepancy). Kept as an array so # the args survive quoting/word-splitting cleanly when expanded. SSH_OPTS=(-p "$NAS_PORT" -i "$NAS_KEY" -o IdentitiesOnly=yes) # Single STRING form of the same pinned-key ssh command, for RSYNC_RSH only: # a real (samba) rsync wants ONE remote-shell string, not an array. Single- # quote the key path defensively so an odd path can't word-split. RSYNC_RSH_CMD="ssh -p $NAS_PORT -i '$NAS_KEY' -o IdentitiesOnly=yes" # Transfer-hop ssh: pinned identity PLUS BatchMode so a password prompt fails # fast instead of hanging mid-deploy. Used by the tar fallback hops. SSH_TRANSFER=(ssh "${SSH_OPTS[@]}" -o BatchMode=yes) # --- Stage 0: preflight ---------------------------------------------------- preflight() { banner "[0/4]" "Preflight checks" if ! command -v rsync >/dev/null 2>&1; then fail "preflight" "rsync not found on PATH. Install rsync (macOS: it ships by default; Linux: your package manager)." 2 fi if ! command -v ssh >/dev/null 2>&1; then fail "preflight" "ssh not found on PATH." 2 fi # The key MUST exist as a file before we attempt any connection — otherwise # ssh silently falls back to agent identities and we lose the determinism # this whole script exists to provide. if [[ ! -f "$NAS_KEY" ]]; then fail "preflight" "SSH key $NAS_KEY not found — set NAS_KEY or generate one (ssh-keygen -t ed25519)." 2 fi log "target: $NAS_USER@$NAS_HOST:$NAS_PORT" log "path: $NAS_PATH" log "ssh key: $NAS_KEY" # Fail-fast connectivity check. BatchMode=yes => never prompt for a password # (so a misconfigured key fails here, loudly, instead of hanging). log "verifying SSH connectivity (pinned key, no password prompt)..." if ! ssh "${SSH_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=8 \ "$NAS_USER@$NAS_HOST" 'true'; then fail "preflight" "SSH connection to $NAS_USER@$NAS_HOST:$NAS_PORT failed with key $NAS_KEY. Confirm the key is authorized on the NAS (ssh-copy-id) and the host/port are correct." 1 fi log "SSH connectivity OK" } # --- Stage 1: test gate ---------------------------------------------------- # Run the suite BEFORE any NAS interaction so a red build fails fast and local, # never leaving a half-synced tree or a broken image on the NAS. `npm test` # resolves to `cd client && npm test` -> `vitest run` (CI mode, non-watch — it # exits, it does not hang). Headless-safe: vitest run needs no TTY. The only # escape hatch is SKIP_TESTS=1 for emergencies; it warns loudly when used. # No secrets are involved or echoed here. stage_test() { banner "[1/4]" "Running test gate (npm test)" if [[ "${SKIP_TESTS:-0}" == "1" ]]; then warn "SKIP_TESTS=1 set — BYPASSING the test gate. Deploying UNTESTED code." return 0 fi if ! command -v npm >/dev/null 2>&1; then fail "test" "npm not found on PATH — cannot run the test gate. Install Node/npm or set SKIP_TESTS=1 to bypass (not recommended)." 2 fi log "running: npm test (vitest run, non-watch)" # Run directly so the real exit code propagates; set -e would also abort, but # this gives a clean stage-labelled failure banner instead of a bare trap. local rc=0 npm test || rc=$? if [[ "$rc" -ne 0 ]]; then fail "test" "Test suite failed (npm test exited $rc). Deploy aborted before touching the NAS. Fix the tests (or SKIP_TESTS=1 to force, not recommended)." "$rc" fi log "tests passed — proceeding to sync" } # --- Transport detection: find a usable (NON-openrsync) rsync -------------- # macOS now ships Apple's "openrsync" as /usr/bin/rsync. openrsync IGNORES both # the `-e` remote-shell option AND the RSYNC_RSH env var, so our pinned ssh key # never reaches ssh, auth falls back to (non-existent) password, and the sync # fails. Detect that here: a usable rsync is one whose `--version` does NOT say # "openrsync". We also probe the common Homebrew install paths in case the user # `brew install rsync`d a real (samba) rsync. Sets $RSYNC_BIN if one is found. detect_usable_rsync() { RSYNC_BIN="" local candidates=() local path_rsync if path_rsync="$(command -v rsync 2>/dev/null)"; then candidates+=("$path_rsync") fi candidates+=(/opt/homebrew/bin/rsync /usr/local/bin/rsync) local cand for cand in "${candidates[@]}"; do [[ -x "$cand" ]] || continue if ! "$cand" --version 2>/dev/null | head -1 | grep -qi 'openrsync'; then RSYNC_BIN="$cand" return 0 fi done return 1 } # --- Stage 2: sync the working tree (rsync if usable, else tar-over-ssh) --- stage_sync() { banner "[2/4]" "Syncing working tree to $NAS_USER@$NAS_HOST:$NAS_PATH" if detect_usable_rsync; then sync_via_rsync else sync_via_tar fi log "sync complete" } # Preferred path: a real rsync. --delete keeps the NAS tree a mirror of local; # the excludes protect NAS-local state (.env* secrets, backups DB dumps) and # local-only build cruft. Trailing slash on the source copies the CONTENTS of # REPO_ROOT into NAS_PATH. RSYNC_RSH carries our pinned-key ssh (incl. BatchMode # so it fails fast rather than hanging on a password prompt). sync_via_rsync() { log "using rsync at $RSYNC_BIN" if ! RSYNC_RSH="$RSYNC_RSH_CMD -o BatchMode=yes" "$RSYNC_BIN" -av --delete \ --exclude '.git/' \ --exclude '._*' \ --exclude 'node_modules/' \ --exclude 'client/node_modules/' \ --exclude 'dist/' \ --exclude 'client/dist/' \ --exclude '.env' \ --exclude '.env.*' \ --exclude '.deploy.env' \ --exclude 'storage-dump.json' \ --exclude '.idea/' \ --exclude 'backups/' \ --exclude 'logs/' \ --exclude 'prototype/' \ --exclude 'prototype.zip' \ --exclude '.claude/' \ --exclude 'claude_artifacts/' \ "$REPO_ROOT/" \ "$NAS_USER@$NAS_HOST:$NAS_PATH/"; then fail "sync" "rsync to $NAS_HOST:$NAS_PATH failed. See rsync output above." fi } # Fallback path (e.g. macOS openrsync): stream a tar of the working tree over # the proven pinned-key ssh and extract it on the NAS. No rsync involved, so # the -e/RSYNC_RSH-ignoring openrsync problem is sidestepped entirely. # # Tradeoff: tar extraction is additive — it does NOT delete stale remote files # the way `rsync --delete` does. Documented loudly below. sync_via_tar() { warn "openrsync detected (Apple's rsync ignores -e/RSYNC_RSH); using tar-over-ssh." warn "Note: stale remote files are NOT deleted in this mode — \`brew install rsync\` to enable --delete." if ! "${SSH_TRANSFER[@]}" "$NAS_USER@$NAS_HOST" "mkdir -p '$NAS_PATH'"; then fail "sync" "could not create remote dir $NAS_PATH on $NAS_HOST (tar-over-ssh)." fi # Pipe local tar -> remote tar extract, capturing the exit code of BOTH sides. # Snapshot ${PIPESTATUS[@]} into an array in the very next command on BOTH # branches so it is read before anything overwrites it (and `set -e` never # aborts mid-stage). local pipe_status=() { COPYFILE_DISABLE=1 tar --no-mac-metadata -czf - \ --exclude='._*' \ --exclude='./.git' \ --exclude='./node_modules' \ --exclude='./client/node_modules' \ --exclude='./dist' \ --exclude='./client/dist' \ --exclude='./.env' \ --exclude='./.env.*' \ --exclude='./.deploy.env' \ --exclude='./storage-dump.json' \ --exclude='./.idea' \ --exclude='./backups' \ --exclude='./logs' \ --exclude='./prototype' \ --exclude='./prototype.zip' \ --exclude='./.claude' \ --exclude='./claude_artifacts' \ -C "$REPO_ROOT" . \ | "${SSH_TRANSFER[@]}" "$NAS_USER@$NAS_HOST" "tar -xzf - -C '$NAS_PATH'" ; } \ && pipe_status=("${PIPESTATUS[@]}") \ || pipe_status=("${PIPESTATUS[@]}") local tar_rc="${pipe_status[0]}" local ssh_rc="${pipe_status[1]}" if [[ "$tar_rc" -ne 0 ]]; then fail "sync" "local tar failed (exit $tar_rc) streaming working tree to $NAS_HOST." fi if [[ "$ssh_rc" -ne 0 ]]; then fail "sync" "remote tar extract on $NAS_HOST failed (exit $ssh_rc) (tar-over-ssh)." fi } # --- Stage 3: run deploy.sh on the NAS ------------------------------------ stage_deploy() { banner "[3/4]" "Running deploy.sh on the NAS" # -t allocates a TTY so deploy.sh's live streaming + colour come through. # printf %q on each forwarded arg makes the remote command robust to args # containing spaces/quotes; the array may be empty, which is fine. local remote_args="" if [[ ${#DEPLOY_ARGS[@]} -gt 0 ]]; then remote_args="$(printf ' %q' "${DEPLOY_ARGS[@]}")" fi local remote_cmd remote_cmd="cd $(printf '%q' "$NAS_PATH") && ./scripts/deploy.sh${remote_args}" # Run directly (not under `if ! ...`) so we can capture the REAL remote exit # code: after `if ! cmd`, $? is the negation's status (0), not the command's. local rc=0 ssh -t "${SSH_OPTS[@]}" "$NAS_USER@$NAS_HOST" "$remote_cmd" || rc=$? if [[ "$rc" -ne 0 ]]; then fail "deploy" "Remote deploy.sh on $NAS_HOST exited non-zero (code $rc). See the deploy output above." "$rc" fi } # --- Final OK banner ------------------------------------------------------- success_banner() { printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" printf '%s%s PUSH + DEPLOY OK%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" printf '%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" log "target: $NAS_USER@$NAS_HOST:$NAS_PATH" log "remote deploy completed successfully" } # --- Main ------------------------------------------------------------------ main() { preflight stage_test stage_sync stage_deploy success_banner } main