#!/usr/bin/env bash # # Scheduled dependency update + vulnerability scan (every ~16 days). # # Driven by the systemd user timer `skatespots-deps.timer` (a daily tick; this # script self-gates to one real run per INTERVAL_DAYS). A second weekly timer # (`skatespots-deps-scan.timer`) runs this with --scan-only for earlier CVE # visibility without touching the lockfile. What it does: # 1. bails if the working tree is dirty (never mixes with your own changes); # 2. scans `npm outdated` + `npm audit` into a dated report; # 3. applies NON-breaking updates (`npm update` + `npm audit fix` — never --force); # 4. runs the full test suite (`npm test`) AND the boot smoke test; # 5. on success commits + pushes the lockfile/package.json (the deploy timer then # ships it); on failure it RESTORES the previous lockfile and reports. # # Flags: # --scan-only report only (no apply, no commit) # --no-push apply + test + commit locally, but don't push # --force ignore the interval gate # # Env: DEPS_INTERVAL_DAYS (default 20), DEPS_STATE_DIR (default ~/.local/state/skatespots-deps) set -uo pipefail REPO="/home/sovereignsk8/SkateSpots" # Resolve the newest nvm Node so a Node upgrade doesn't silently break the # timer (the old hard-coded v22.23.1 path did). if [ -d "$HOME/.nvm/versions/node" ]; then NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)" [ -n "$NODE_BIN" ] && export PATH="$NODE_BIN:$PATH" fi export PATH="$HOME/go/bin:$PATH" # The job runs the full gate itself; don't make the pre-push hook repeat it. export SKIP_PREFLIGHT=1 cd "$REPO" INTERVAL_DAYS="${DEPS_INTERVAL_DAYS:-16}" STATE_DIR="${DEPS_STATE_DIR:-$HOME/.local/state/skatespots-deps}" mkdir -p "$STATE_DIR" LAST="$STATE_DIR/last-run" REPORT="$STATE_DIR/report-$(LC_ALL=C date +%F).txt" FAILURE_FILE="$STATE_DIR/last-failure" FAILURE_DIR_BASE="$STATE_DIR/failures" OUTDATED_JSON="$STATE_DIR/outdated-$(LC_ALL=C date +%F).json" # OSV-Scanner (broader than npm audit: queries the OSV database for the lockfile). # Installed with: go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest OSV_BIN="${OSV_BIN:-$HOME/go/bin/osv-scanner}" SCAN_ONLY=0; NO_PUSH=0; FORCE=0 for a in "$@"; do case "$a" in --scan-only) SCAN_ONLY=1 ;; --no-push) NO_PUSH=1 ;; --force) FORCE=1 ;; esac done log() { echo "[$(date -Is)] $*"; } notify() { command -v notify-send >/dev/null 2>&1 && notify-send -a SkateSpots "Dependency job" "$1" 2>/dev/null || true } finish_timer() { date +%s > "$LAST"; } # ── fallback helpers ──────────────────────────────────────────────────────── # Fast gate for the per-package fallback. The full `npm test` + smoke still runs # once at the end; this keeps the fallback from taking an hour. fast_gate() { npx tsc --noEmit >/dev/null 2>&1 \ && npx tsc -p cvm-server/tsconfig.json >/dev/null 2>&1 \ && npx tsc -p ai-proxy/tsconfig.json >/dev/null 2>&1 \ && npx tsc -p push-service/tsconfig.json >/dev/null 2>&1 \ && node scripts/check-i18n.mjs >/dev/null 2>&1 \ && npx vitest run --reporter=dot --silent >/dev/null 2>&1 } # If the blanket `npm update` fails, retry only patch-level bumps one package at # a time; keep each one that passes the fast gate and skip the rest. This keeps # safe updates instead of reverting the whole batch. Requires OUTDATED_JSON # captured before the full update. patch_only_fallback() { local tmp list pkg kept=() skipped=() tmp="$(mktemp -d)" cp package.json package-lock.json "$tmp"/ 2>/dev/null || true list="$(node -e ' const fs = require("fs"); let j = {}; try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch {} for (const [name, v] of Object.entries(j)) { const cur = String(v.current || ""), want = String(v.wanted || ""); if (!cur || !want || cur === want) continue; const a = cur.split("."), b = want.split("."); if (a.length < 3 || b.length < 3) continue; if (a[0] === b[0] && a[1] === b[1] && Number(b[2]) > Number(a[2])) console.log(name); } ' "$OUTDATED_JSON" 2>/dev/null || true)" if [ -z "$list" ]; then log "fallback: no patch-only updates available" rm -rf "$tmp" return 1 fi while IFS= read -r pkg; do [ -n "$pkg" ] || continue log "fallback: trying $pkg" if npm update "$pkg" >/dev/null 2>&1 \ && npm dedupe >/dev/null 2>&1 \ && fast_gate; then kept+=("$pkg") # New baseline for the next package. cp package.json package-lock.json "$tmp"/ 2>/dev/null || true else log "fallback: $pkg failed the fast gate — reverting it" cp "$tmp/package.json" "$tmp/package-lock.json" . 2>/dev/null || true npm i --silent >/dev/null 2>&1 || true skipped+=("$pkg") fi done <<< "$list" rm -rf "$tmp" log "fallback: kept ${#kept[@]} (${kept[*]:-none}), skipped ${#skipped[@]} (${skipped[*]:-none})" [ "${#kept[@]}" -gt 0 ] } # ── interval gate ─────────────────────────────────────────────────────────── if [ "$FORCE" -eq 0 ] && [ "$SCAN_ONLY" -eq 0 ] && [ -f "$LAST" ]; then last=$(cat "$LAST" 2>/dev/null || echo 0) now=$(date +%s) elapsed=$(( now - last )) if [ "$elapsed" -lt $(( INTERVAL_DAYS * 86400 )) ]; then log "not due yet ($(( (INTERVAL_DAYS * 86400 - elapsed) / 86400 ))d left)" exit 0 fi fi # ── scan ──────────────────────────────────────────────────────────────────── { echo "=== SkateSpots dependency scan — $(date -Is) ===" echo echo "=== npm outdated ===" npm outdated 2>&1 || true echo echo "=== npm audit ===" npm audit 2>&1 || true echo echo "=== osv-scanner (OSV database) ===" if [ -x "$OSV_BIN" ]; then "$OSV_BIN" scan source -L package-lock.json 2>&1 || true else echo "osv-scanner not installed (expected $OSV_BIN)" fi } | tee "$REPORT" # The OSV summary line (e.g. "Total 27 packages affected by 59 known ...") is the # number worth surfacing in notifications; npm audit's counts are narrower. OSV_SUMMARY="$(grep -E '^Total [0-9]+ packages affected' "$REPORT" | tail -1)" [ -n "$OSV_SUMMARY" ] || OSV_SUMMARY="OSV scan: no summary (scanner missing?)" # Rotate diagnostics so the state dir cannot grow forever. ls -1t "$STATE_DIR"/report-*.txt 2>/dev/null | tail -n +13 | xargs -r rm -f ls -1dt "$FAILURE_DIR_BASE"/* 2>/dev/null | tail -n +9 | xargs -r rm -rf if [ "$SCAN_ONLY" -eq 1 ]; then log "scan-only; report: $REPORT" echo "$OSV_SUMMARY" PREV_REPORT="$(ls -1t "$STATE_DIR"/report-*.txt 2>/dev/null | sed -n '2p' | head -1 || true)" PREV_OSV="" if [ -n "$PREV_REPORT" ]; then PREV_OSV="$(grep -E '^Total [0-9]+ packages affected' "$PREV_REPORT" | tail -1 || true)" fi if [ "$OSV_SUMMARY" != "$PREV_OSV" ]; then notify "Vulnerability scan changed: $OSV_SUMMARY (report: $REPORT)" timeout 90 "$REPO/scripts/send-ops-alert.sh" "Security scan changed. $OSV_SUMMARY. Check the server report for details." || true else log "scan unchanged since the previous report — not alerting" fi exit 0 fi # ── never mix with the user's uncommitted work ────────────────────────────── if [ -n "$(git status --porcelain)" ]; then log "working tree is dirty — skipping (commit or stash first)" exit 0 fi cp package.json "$STATE_DIR/package.json.bak" 2>/dev/null || true cp package-lock.json "$STATE_DIR/package-lock.json.bak" 2>/dev/null || true # Snapshot the pre-update outdated list for the patch-only fallback. npm outdated --json --depth=0 > "$OUTDATED_JSON" 2>/dev/null || true # ── apply non-breaking updates ────────────────────────────────────────────── log "applying non-breaking updates (npm update + npm dedupe + npm audit fix)…" npm update 2>&1 | tail -3 # Collapse duplicate transitive copies (e.g. @tanstack/query-core, @nostrify/*). # Without this a minor bump can nest a second copy and break type identity in tsc. npm dedupe 2>&1 | tail -2 npm audit fix 2>&1 | tail -3 { echo; echo "=== post-update npm audit ==="; npm audit 2>&1 || true echo; echo "=== post-update osv-scanner ===" if [ -x "$OSV_BIN" ]; then "$OSV_BIN" scan source -L package-lock.json 2>&1 || true; fi } | tee -a "$REPORT" # ── gate: full tests + boot smoke ─────────────────────────────────────────── log "running tests + smoke…" if npm test >"$STATE_DIR/test.log" 2>&1 && node scripts/smoke-test.mjs >"$STATE_DIR/smoke.log" 2>&1; then if [ -n "$(git status --porcelain package.json package-lock.json)" ]; then git add package.json package-lock.json git commit -m "chore(deps): scheduled dependency update + audit ($(LC_ALL=C date +%F))" >/dev/null 2>&1 || true if [ "$NO_PUSH" -eq 0 ]; then # Bound the push: the git remote signs via the remote signer and can stall. if timeout 180 git push origin main >"$STATE_DIR/push.log" 2>&1; then log "pushed dependency update" notify "Dependency update applied, tests pass, pushed. ${OSV_SUMMARY}" else log "push failed/skipped (see $STATE_DIR/push.log)" notify "Dependency update committed locally but the push failed. ${OSV_SUMMARY}" fi else log "committed locally (--no-push)" notify "Dependency update applied and committed locally. ${OSV_SUMMARY}" fi else log "no dependency changes to commit" notify "Dependency scan complete: nothing to update. ${OSV_SUMMARY}" fi finish_timer rm -f "$FAILURE_FILE" log "done" else log "full update FAILED — saving diagnostics and trying the patch-only fallback" FAIL_DIR="$FAILURE_DIR_BASE/$(date +%F-%H%M)" mkdir -p "$FAIL_DIR" cp "$STATE_DIR/test.log" "$STATE_DIR/smoke.log" "$FAIL_DIR"/ 2>/dev/null || true cp package.json package-lock.json "$FAIL_DIR"/ 2>/dev/null || true printf '%s\n' "full update failed at $(date -Is)" "see $FAIL_DIR/test.log" > "$FAIL_DIR/summary.txt" echo "$(date -Is) full update failed — $FAIL_DIR" > "$FAILURE_FILE" # Restore the original tree before trying the fallback. cp "$STATE_DIR/package.json.bak" package.json 2>/dev/null || true cp "$STATE_DIR/package-lock.json.bak" package-lock.json 2>/dev/null || true npm i --silent >/dev/null 2>&1 || true if patch_only_fallback \ && npm test >"$STATE_DIR/test.log" 2>&1 \ && node scripts/smoke-test.mjs >"$STATE_DIR/smoke.log" 2>&1; then { echo; echo "=== post-fallback npm audit ==="; npm audit 2>&1 || true echo; echo "=== post-fallback osv-scanner ===" if [ -x "$OSV_BIN" ]; then "$OSV_BIN" scan source -L package-lock.json 2>&1 || true; fi } | tee -a "$REPORT" OSV_SUMMARY="$(grep -E '^Total [0-9]+ packages affected' "$REPORT" | tail -1)" [ -n "$OSV_SUMMARY" ] || OSV_SUMMARY="OSV scan: no summary" if [ -n "$(git status --porcelain package.json package-lock.json)" ]; then git add package.json package-lock.json git commit -m "chore(deps): scheduled dependency update + audit ($(LC_ALL=C date +%F))" >/dev/null 2>&1 || true if [ "$NO_PUSH" -eq 0 ]; then if timeout 180 git push origin main >"$STATE_DIR/push.log" 2>&1; then log "pushed patch-only dependency update" notify "Dependency update applied via the patch-only fallback, tests pass, pushed. ${OSV_SUMMARY}" else log "push failed/skipped (see $STATE_DIR/push.log)" notify "Patch-only dependency update committed locally but the push failed. ${OSV_SUMMARY}" fi else log "committed locally (--no-push)" notify "Patch-only dependency update applied and committed locally. ${OSV_SUMMARY}" fi else log "patch-only fallback produced no package changes" notify "Dependency scan complete: nothing safe to update. ${OSV_SUMMARY}" fi finish_timer rm -f "$FAILURE_FILE" log "done (patch-only fallback)" exit 0 fi # Both the full and the patch-only update failed: restore and retry in ~3 days. mkdir -p "$FAIL_DIR/fallback" cp "$STATE_DIR/test.log" "$STATE_DIR/smoke.log" "$FAIL_DIR/fallback"/ 2>/dev/null || true cp package.json package-lock.json "$FAIL_DIR/fallback"/ 2>/dev/null || true cp "$STATE_DIR/package.json.bak" package.json 2>/dev/null || true cp "$STATE_DIR/package-lock.json.bak" package-lock.json 2>/dev/null || true npm i --silent >/dev/null 2>&1 || true RETRY_DAYS=3; [ "$INTERVAL_DAYS" -gt 4 ] || RETRY_DAYS=1 echo $(( $(date +%s) - (INTERVAL_DAYS - RETRY_DAYS) * 86400 )) > "$LAST" notify "Dependency update REVERTED — full and patch fallback failed (see $FAIL_DIR). ${OSV_SUMMARY}" timeout 90 "$REPO/scripts/send-ops-alert.sh" "The dependency update failed and was rolled back. The app is unchanged. The next retry is in about 3 days." || true exit 1 fi