#!/usr/bin/env bash # # Scheduled dependency update + vulnerability scan (every ~20 days). # # Driven by the systemd user timer `skatespots-deps.timer` (a daily tick; this # script self-gates to one real run per INTERVAL_DAYS). 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" export PATH="/home/sovereignsk8/.nvm/versions/node/v22.23.1/bin:$PATH" cd "$REPO" INTERVAL_DAYS="${DEPS_INTERVAL_DAYS:-20}" 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" # 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"; } # ── 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?)" if [ "$SCAN_ONLY" -eq 1 ]; then log "scan-only; report: $REPORT" echo "$OSV_SUMMARY" 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 # ── 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 log "done" else log "tests FAILED — restoring the previous lockfile" 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 # A failed run must NOT consume the whole interval: retry in ~3 days instead. RETRY_DAYS=3; [ "$INTERVAL_DAYS" -gt 4 ] || RETRY_DAYS=1 echo $(( $(date +%s) - (INTERVAL_DAYS - RETRY_DAYS) * 86400 )) > "$LAST" notify "Dependency update REVERTED — tests failed (see $STATE_DIR/test.log). ${OSV_SUMMARY}" exit 1 fi