From 4b03a11fe1854840a84e80008d083b56b05b0b8c Mon Sep 17 00:00:00 2001 From: Jeff Codling Date: Mon, 11 May 2026 10:04:20 -0400 Subject: [PATCH 01/16] feat: add Docker deployment for Synology NAS Container Station MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New files: - Dockerfile: Alpine-based Bun image, non-root user, one-shot CMD - docker-compose.yml: Alternative Compose spec reference - .dockerignore: Excludes node_modules, .env, logs, reports from build context - scripts/deploy-nas.sh: One-command deploy — builds image locally, transfers to NAS via SCP, loads image, starts container with volumes - DOCKER-DEPLOY.md: Full deployment guide for Container Station Modified: - scripts/run.sh: Detects container env, skips caffeine/pmset in container - README.md: Added Deployment options section, updated stack/scheduler, added NAS deployment instructions in Setup and Automation sections --- .dockerignore | 31 +++++++ DOCKER-DEPLOY.md | 189 ++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 35 ++++++++ README.md | 67 ++++++++++++--- docker-compose.yml | 27 ++++++ scripts/deploy-nas.sh | 170 +++++++++++++++++++++++++++++++++++++ scripts/run.sh | 40 +++++++-- 7 files changed, 540 insertions(+), 19 deletions(-) create mode 100644 .dockerignore create mode 100644 DOCKER-DEPLOY.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 scripts/deploy-nas.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8360a8f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Version control +.git +.gitignore + +# IDE +.vscode + +# Dependencies (reinstalled inside container) +node_modules + +# Mac-specific artefacts +.DS_Store + +# Docker files (don't nest) +Dockerfile +docker-compose*.yml +.dockerignore + +# Documentation +README.md +DOCKER-DEPLOY.md + +# Logs & reports (mounted as volumes at runtime) +logs/ +reports/ + +# Feedback historical (temporary, cleaned up by the script) +feedback-historical/ + +# Local environment file (injected at runtime) +.env diff --git a/DOCKER-DEPLOY.md b/DOCKER-DEPLOY.md new file mode 100644 index 0000000..51811e9 --- /dev/null +++ b/DOCKER-DEPLOY.md @@ -0,0 +1,189 @@ +# Docker Deployment to Synology NAS + +This project can run entirely inside a lightweight Docker container on your Synology NAS via **Container Station** (the free Docker plugin for DSM). The entire pipeline runs inside an Alpine-based Bun container — no need for your Mac to be on. + +## Quick Deploy (CLI — Recommended) + +One deployment script, zero NAS CLI knowledge needed: + +```bash +# One-time setup on your Mac +brew install --cask docker # Docker Desktop CLI +brew install sshpass rsync # NAS communication tools + +# Deploy +export NAS_IP=192.168.1.100 # your NAS IP +export NAS_USER=admin # your NAS username +export NAS_PASS=yourpassword # your NAS password +./scripts/deploy-nas.sh +``` + +This script: +1. Builds the Docker image locally on your Mac +2. Saves and SSH-transfers the image to your NAS +3. Loads the image on the NAS +4. Copies config files and source code +5. Starts the container with all volumes and env files mounted + +## Manual Deployment via Container Station GUI + +If you prefer the Container Station GUI instead of the CLI script: + +1. **Transfer files via File Station:** + - Copy the entire project folder to `/docker/dailyreport/` on your NAS + +2. **Build image via Container Station:** + - Open **Container Station** → **Created Image** → **Create from URL** + - Set URL to local path: `/docker/dailyreport/` + - Container Station will find the `Dockerfile` and build + +3. **Create container:** + - Open **Container Station** → **Container** → **Create** + - Select image: `dailyreport:latest` + - Set scheduling: **Daily at 03:00**, task: **Start** + - Advanced settings: + - **Volume:** Mount `/volume1/docker/dailyreport/` → `/app` (Read/Write) + - **Environment:** Add one variable `ENV_FILE_PATH` = `/app/.env` + - **Network:** Use `bridge` (default) + - Start the container + +## Architecture + +``` +┌─────────────────────┐ ┌─────────────────────────────┐ +│ Your Mac │ │ Synology NAS │ +│ │ scp │ │ +│ ./scripts/ │ ────────► │ /volume1/docker/dailyreport│ +│ deploy-nas.sh ─────► │ │ ├── reports/ (persisted) │ +│ │ rsync │ ├── config/ (persisted) │ +│ local docker build │ │ ├── logs/ (persisted) │ +│ + image save │ │ └── src/ (application) │ +│ │ │ │ +│ │ │ Container Station │ +│ │ │ ┌──────────────────────┐ │ +│ │ │ │ dailyreport_daily │ │ +│ │ │ │ oven/bun:1-alpine │ │ +│ │ │ │ bun run generate │ │ +│ │ │ └──────────────────────┘ │ +│ │ │ ▲ │ +│ │ │ scheduled task │ +└─────────────────────┘ └─────────────────────────────┘ + │ + │ SFTP + ▼ + ┌─────────────────┐ + │ IONOS SFTP │ + │ (web UI host) │ + └─────────────────┘ +``` + +## File Persistence + +All state persists across container recreations via bind mounts: + +| Path (inside container) | NAS Host Path | Purpose | +|---|---|---| +| `/app/reports/` | `/volume1/docker/dailyreport/reports/` | Generated Markdown reports (YYYY-MM-DD.md) | +| `/app/config/` | `/volume1/docker/dailyreport/config/` | interests.yaml, seen-urls.json, feedback-weights.json, blacklist.json | +| `/app/logs/` | `/volume1/docker/dailyreport/logs/` | dailyreport.log, access.log | +| `/app/.env` | `/volume1/docker/dailyreport/.env` | SFTP and OAuth credentials (DO NOT commit to git) | +| `/app/` | `/volume1/docker/dailyreport/` | Application source code | + +## Managing the Container + +### View logs +```bash +ssh admin@192.168.1.100 +docker logs dailyreport_daily --tail 50 + +# Follow in real time: +docker logs -f dailyreport_daily +``` + +### View generated reports +```bash +ssh admin@192.168.1.100 +docker exec dailyreport_daily ls /app/reports/ +docker exec dailyreport_daily cat /app/reports/2025-05-08.md +``` + +### Re-deploy after code changes +```bash +# From your Mac: +export NAS_IP=192.168.1.100 +export NAS_USER=admin +export NAS_PASS=yourpassword +./scripts/deploy-nas.sh # stops old, builds new, starts fresh container +``` + +### Manual full rebuild +```bash +ssh admin@192.168.1.100 +docker stop dailyreport_daily +docker rm dailyreport_daily +docker rmi dailyreport:latest +./scripts/deploy-nas.sh # rebuild from scratch +``` + +## Scheduling via Container Station (One-Time Setup) + +The container exits after completing its task. For it to run daily, you need a **Scheduled Task**: + +1. Open DSM → **Container Station** +2. Click **Scheduled Task** in the left sidebar +3. Click **Create** +4. Select container: **dailyreport_daily** +5. Schedule: **Every day** at **03:00** +6. Task: **Start** +7. Save + +The container will start at 03:00, run for ~3-5 seconds, then exit. Container Station restarts it at the next daily trigger. + +## Troubleshooting + +### Container exits immediately with an error +```bash +ssh admin@192.168.1.100 +docker logs dailyreport_daily --tail 50 +``` +Common causes: missing `.env` on NAS, wrong SFTP credentials, NAS can't reach the internet. + +### Can't reach SFTP host from NAS +The NAS needs outbound HTTPS and SFTP access: +```bash +ssh admin@192.168.1.100 +docker exec dailyreport_daily curl -Is https://google.com +docker exec dailyreport_daily curl -Is https://home554762802.1and1-data.host +``` + +### .env not being read by the container +Verify the `.env` file exists on the NAS: +```bash +ssh admin@192.168.1.100 +cat /volume1/docker/dailyreport/.env +``` +It must have the FTP variables: `FTP_HOST`, `FTP_USER`, `FTP_PASS`, optionally `TARGET_DIR`. + +### Container reports "image not found" +```bash +ssh admin@192.168.1.100 +docker images | grep dailyreport +``` +If missing, re-run `./scripts/deploy-nas.sh`. + +### Logs show permission errors +The `Dockerfile` creates an `appuser` (UID 1000) with proper ownership. If your NAS has different UID/GID mappings, you may need to adjust the Dockerfile's `USER` directive or set appropriate file permissions on the NAS host directories. + +## What's Inside the Container + +| Path | Purpose | +|---|---| +| `/app/` | Application root directory | +| `/app/src/` | TypeScript source files | +| `/app/src/fetchers/` | HN, Reddit, RSS fetcher modules | +| `/app/config/` | `interests.yaml`, `seen-urls.json`, `feedback-weights.json` | +| `/app/reports/` | Generated Markdown reports (YYYY-MM-DD.md) | +| `/app/logs/` | `dailyreport.log`, `access.log` | +| `/app/node_modules/` | Production dependencies (fast-xml-parser, js-yaml, ssh2-sftp-client) | + +The container is Alpine-based (~80 MB image) running Bun under a non-root user for security. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e9dfff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# ============================================================ +# DailyReport — Dockerfile +# ============================================================ +# Built for Synology Container Station and any standard Docker +# runtime. Alpine-based Bun image keeps the image ~80 MB. +# ============================================================ + +FROM oven/bun:1-alpine + +# Install git (needed for feedback git commit workflow) +# and curl (useful for health checks or the deploy script) +RUN apk add --no-cache git curl + +# Create non-root user for security +RUN addgroup -g 1000 appgroup && \ + adduser -u 1000 -G appgroup -D appuser + +WORKDIR /app + +# --- Dependency install (layer-cached: changes when these change) --- +COPY package.json bun.lock ./ +RUN bun install --production + +# --- Application source --- +COPY . . + +# Create runtime directories with correct ownership +RUN mkdir -p /app/reports /app/logs /app/config && \ + chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Default: run the curation pipeline (one-shot, exits when done) +CMD ["bun", "run", "generate"] diff --git a/README.md b/README.md index a2d05ca..5e4eabd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Zero external AI dependencies — runs entirely locally in ~1 second per curatio 4. **Learn** — Aggregates feedback from all historical remote reports, extracts keywords from voted articles, nudges `feedback-weights.json` by ±0.1 per keyword. Weights decay gradually so old signals don't dominate forever. 5. **Publish** — Renders a Markdown report, uploads it to the hosted web UI via SFTP -Runs automatically at 3 AM daily via macOS launchd. +Runs automatically at 3 AM daily via macOS launchd **or** Synology Container Station Docker scheduler. --- @@ -58,19 +58,26 @@ The top 5 articles per category by total score are selected. The wildcard is the | Remote sync | `ssh2-sftp-client` (IONOS SFTP) | | Web UI | PHP + Vanilla JS (hosted on IONOS) | | Auth | Google OAuth 2.0 (only allows access from configured email address) | -| Scheduler | macOS launchd | +| Scheduler | macOS launchd **or** Synology Container Station (see [DOCKER-DEPLOY.md](DOCKER-DEPLOY.md)) | --- ## Requirements -- **Bun** runtime (for TypeScript execution) +### For all deployment options: +- **Bun** runtime (v1.x, inside Docker or native) - **IONOS SFTP** account (for hosting and report sync) -- **Google OAuth** credentials (for web UI authentication — only allows access from configured email address) -- **macOS** (for launchd automation) +- **Google OAuth** credentials (for web UI authentication) No AI model or external API dependency required. +### For macOS launchd (current deployment): +- **macOS** (for launchd automation + `pmset` wake schedule) + +### For Synology NAS (Docker deployment): +- **Synology NAS** with **Container Station** plugin installed +- macOS (for building image and deploying via `scripts/deploy-nas.sh`) + --- ## Project structure @@ -104,12 +111,17 @@ No AI model or external API dependency required. │ └── .htaccess # IONOS routing config │ ├── scripts/ -│ ├── install-launchd.sh # Install macOS launchd 3 AM job + persistent wake schedule -│ ├── run.sh # Wrapper: loads .env, sets PATH, runs pipeline -│ └── monitor.sh # Colourised live log viewer +│ ├── install-launchd.sh # Install macOS launchd 3 AM job + persistent wake schedule +│ ├── deploy-nas.sh # One-shot deploy to Synology NAS (builds + pushes Docker image) +│ ├── run.sh # Wrapper: loads .env, sets PATH, runs pipeline +│ └── monitor.sh # Colourised live log viewer │ -├── reports/ # Generated Markdown reports (YYYY-MM-DD.md) -└── logs/ # Execution logs (dailyreport.log, dailyreport.err) +├── Dockerfile # Alpine-based Bun image for Synology/Container Station +├── docker-compose.yml # Container spec (volumes, scheduling) +├── .dockerignore # Excludes node_modules, .env, git etc from build context +├── DOCKER-DEPLOY.md # Full Synology NAS deployment guide +├── reports/ # Generated Markdown reports (YYYY-MM-DD.md) +└── logs/ # Execution logs (dailyreport.log, dailyreport.err) ``` --- @@ -187,7 +199,9 @@ Starts a dev server at http://localhost:3001 with the report viewer (no OAuth re --- -## Automation (macOS launchd) +## Automation + +### macOS launchd (current, local deployment) ```bash bash scripts/install-launchd.sh @@ -218,6 +232,37 @@ launchctl unload ~/Library/LaunchAgents/com.dailyreport.generate.plist rm ~/Library/LaunchAgents/com.dailyreport.generate.plist ``` +### Synology NAS Docker deployment (recommended for always-on NAS) + +For full Docker/Synology NAS deployment instructions, see [DOCKER-DEPLOY.md](DOCKER-DEPLOY.md). + +Quick summary: + +```bash +# One-time prerequisites +brew install --cask docker +brew install sshpass rsync + +# Deploy to NAS (once) +export NAS_IP=192.168.1.100 +export NAS_USER=admin +export NAS_PASS=yourpassword +./scripts/deploy-nas.sh +``` + +Then set up a **Scheduled Task** in Synology Container Station: +- Container: `dailyreport_daily` +- Schedule: Every day at 03:00 +- Task: `Start` + +The container runs for ~5 seconds, then exits. Container Station restarts it at the next scheduled time. + +View NAS container logs: +```bash +ssh admin@192.168.1.100 +docker logs dailyreport_daily --tail 30 +``` + --- ## Feedback learning diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dcc420a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +# ============================================================ +# DailyReport — Docker Compose +# +# For users who prefer `docker-compose up` over the CLI deploy +# script. Not required — the deploy script (scripts/deploy-nas.sh) +# handles everything. +# +# Usage: +# docker-compose -p dailyreport up -d +# docker-compose -p dailyreport down +# ============================================================ + +services: + dailyreport: + build: + context: . + dockerfile: Dockerfile + container_name: dailyreport_daily + restart: "no" # one-shot — exits after generating report + env_file: + - .env # SFTP and OAuth credentials + volumes: + - dailyreport-data:/app # persists reports, config, logs + +volumes: + dailyreport-data: + driver: local diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh new file mode 100644 index 0000000..0f42a7a --- /dev/null +++ b/scripts/deploy-nas.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# ============================================================ +# deploy-nas.sh — Deploy DailyReport to Synology NAS +# +# Method: Build locally → save image → SSH to NAS → load image +# → run container → one-time guide for Container Station scheduling. +# +# Prerequisites (install once on your Mac): +# brew install --cask docker +# brew install sshpass rsync jq +# +# Usage: +# export NAS_IP=192.168.1.100 +# export NAS_USER=admin +# export NAS_PASS=yourpassword +# ./scripts/deploy-nas.sh +# +# ============================================================ + +set -euo pipefail + +NAS_IP="${NAS_IP:-"192.168.1.100"}" +NAS_USER="${NAS_USER:-"admin"}" +NAS_PASS="${NAS_PASS:""}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +IMAGE_TAG="dailyreport:latest" +IMAGE_FILE="/tmp/dailyreport-image.tar" +CONTAINER_NAME="dailyreport_daily" +DOCKER_DIR="/docker/dailyreport" + +# --- Helpers --------------------------------------------------- +info() { echo -e "\033[32m[DEPLOY]\033[0m $*"; } +warn() { echo -e "\033[33m[DEPLOY]\033[0m $*"; } +error() { echo -e "\033[31m[DEPLOY]\033[0m $*" >&2; exit 1; } + +# --- Pre-flight checks ---------------------------------------- +[ -z "$NAS_PASS" ] && error "Set NAS_PASS or export NAS_PASS=your-password" +command -v docker || error "docker not found — run: brew install --cask docker" +command -v sshpass || warn "sshpass not found — run: brew install sshpass" +command -v rsync || warn "rsync not found — run: brew install rsync" + +# --- Clean up previous deploy state --------------------------- +info "Cleaning up previous deploy artifacts..." +sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ + "docker stop $CONTAINER_NAME 2>/dev/null || true; docker rm $CONTAINER_NAME 2>/dev/null || true" +rm -f "$IMAGE_FILE" + +# --- Step 1: Build image locally ------------------------------ +info "Building Docker image locally..." +pushd "$PROJECT_DIR" >/dev/null +docker build -t "$IMAGE_TAG" . 2>&1 | tail -5 +IMG_SIZE=$(docker image inspect --format '{{.Size}}' "$IMAGE_TAG") +popd >/dev/null +info "Image built: $IMAGE_TAG ($(( IMG_SIZE / 1024 / 1024 ))MB)" + +# --- Step 2: Save image and transfer to NAS ------------------- +info "Saving and transferring image to NAS ($NAS_IP)..." +docker save -o "$IMAGE_FILE" "$IMAGE_TAG" +scp "$IMAGE_FILE" "${NAS_USER}@${NAS_IP}:/tmp/" +rm -f "$IMAGE_FILE" +info "Image transferred to NAS." + +# --- Step 3: Load image on NAS -------------------------------- +info "Loading image on NAS..." +sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ + "docker load -i /tmp/dailyreport-image.tar && rm /tmp/dailyreport-image.tar" +info "Image loaded on NAS: $IMAGE_TAG" + +# --- Step 4: Prepare host directories on NAS ------------------ +info "Preparing storage folders on NAS..." +sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ + "mkdir -p /volume1${DOCKER_DIR}/{reports,logs,config}" + +# --- Step 5: Copy application source to NAS ------------------- +info "Copying application source to NAS..." +rsync -avz --progress \ + -e "sshpass -p '$NAS_PASS' ssh -o StrictHostKeyChecking=no" \ + --exclude='.git/' \ + --exclude='node_modules/' \ + --exclude='.DS_Store' \ + --exclude='reports/' \ + --exclude='logs/' \ + --exclude='feedback-historical/' \ + "$PROJECT_DIR/" "${NAS_USER}@${NAS_IP}:/volume1${DOCKER_DIR}/" + +# Copy .env separately (excluded by rsync .dockerignore pattern) +sshpass -p "$NAS_PASS" scp -o StrictHostKeyChecking=no \ + "$PROJECT_DIR/.env" "${NAS_USER}@${NAS_IP}:${DOCKER_DIR}/.env" + +info "Files copied. Starting container..." + +# --- Step 6: Start container with env vars baked in ----------- +# Note: We pass env vars directly rather than --env-file for max +# compatibility with Synology Container Station (some versions +# have quirks with --env-file relative paths). +info "Starting container on NAS..." + +sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" " +docker stop $CONTAINER_NAME 2>/dev/null || true +docker rm $CONTAINER_NAME 2>/dev/null || true + +docker run -d \ + --name $CONTAINER_NAME \ + --restart no \ + $(if [ -f /volume1${DOCKER_DIR}/.env ]; then echo '--env-file /volume1/docker/dailyreport/.env'; fi) \ + -v /volume1${DOCKER_DIR}:/app \ + $IMAGE_TAG +" + +# --- Step 7: Verify ------------------------------------------- +echo "" +WAIT=0 +CONTAINER_STATUS="unknown" +while [ $WAIT -lt 10 ]; do + CONTAINER_STATUS=$(sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ + "docker inspect --format '{{.State.Status}}' $CONTAINER_NAME" 2>/dev/null || echo "unknown") + + if [ "$CONTAINER_STATUS" = "running" ]; then + info "✅ Container is running on NAS!" + echo "" + info "Recent container logs:" + sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ + "docker logs --tail 10 $CONTAINER_NAME" + break + elif [ "$CONTAINER_STATUS" = "exited" ]; then + warn "⚠ Container exited (expected — it runs then exits after curation). Check this for errors:" + sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ + "docker logs --tail 20 $CONTAINER_NAME" + break + fi + + WAIT=$((WAIT + 1)) + echo " Waiting for container to start... ($WAIT/10)" + sleep 2 +done + +[ $WAIT -eq 10 ] && warn "⚠ Could not verify container status. Check manually:" +[ $WAIT -eq 10 ] && echo " docker ps -a --filter name=$CONTAINER_NAME" + +# ====================================================================== +# PRINT NEXT STEPS +# ====================================================================== +echo "" +echo "=============================================================" +echo " DEPLOYMENT COMPLETE" +echo "=============================================================" +echo "" +echo "Container status on NAS ($NAS_IP):" +echo " docker ps -a --filter name=$CONTAINER_NAME" +echo "" +echo "⚡ IMPORTANT — Schedule the container to run daily:" +echo "" +echo " 1. Open DSM → Container Station" +echo " 2. Find the project named \"docker\" containing $CONTAINER_NAME" +echo " 3. Click \"Scheduled Task\" (left sidebar)" +echo " 4. Click \"Create\"" +echo " 5. Container: $CONTAINER_NAME" +echo " 6. Schedule: Every day at 03:00" +echo " 7. Task: Start" +echo " 8. Save" +echo "" +echo "Verify a run:" +echo " docker exec ${CONTAINER_NAME} ls /app/reports/" +echo " docker logs ${CONTAINER_NAME} --tail 30" +echo "" +echo "Re-deploy after source changes:" +echo " ./scripts/deploy-nas.sh" +echo "=============================================================" diff --git a/scripts/run.sh b/scripts/run.sh index f4bc66a..8072714 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -4,20 +4,44 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -# Load all secrets at runtime (not baked into plist) +# Load all secrets at runtime (not baked into image) if [ -f "$PROJECT_DIR/.env" ]; then set -a source "$PROJECT_DIR/.env" set +a fi -export PATH="$HOME/.bun/bin:/usr/local/bin:/usr/bin:/bin" +# Detect runtime environment +IN_CONTAINER=0 +if [ -f /.dockerenv ] || [ -n "$DOCKER_CONTAINER" ]; then + IN_CONTAINER=1 +fi -echo "[$(date)] Starting daily report" >> "$PROJECT_DIR/logs/dailyreport.log" -caffeinate -i "$HOME/.bun/bin/bun" run "$PROJECT_DIR/src/index.ts" -echo "[$(date)] Done" >> "$PROJECT_DIR/logs/dailyreport.log" +# Runtime setup +if [ "$IN_CONTAINER" = "1" ]; then + # Container: bun is installed in the image + export PATH="/usr/local/bin:/usr/bin:/bin" +else + # macOS: use bun from PATH + export PATH="$HOME/.bun/bin:/usr/local/bin:/usr/bin:/bin" +fi -# Ensure recurring wake at 2:55am — skip sudo if already scheduled (launchd has no TTY) -if ! pmset -g sched 2>/dev/null | grep -q "2:55"; then - sudo pmset repeat wake MTWRFSU 02:55:00 2>/dev/null || echo "[$(date)] WARNING: pmset repeat wake failed — run manually: sudo pmset repeat wake MTWRFSU 02:55:00" >> "$PROJECT_DIR/logs/dailyreport.log" +echo "[$(date)] Starting daily report (container=$IN_CONTAINER)" >> "$PROJECT_DIR/logs/dailyreport.log" + +# Run the pipeline +if [ "$IN_CONTAINER" = "1" ]; then + # In container: just run bun, no caffeine needed + bun run "$PROJECT_DIR/src/index.ts" +else + # On macOS: keep the machine awake via caffeine, and wake it the next day + caffeinate -i "$HOME/.bun/bin/bun" run "$PROJECT_DIR/src/index.ts" + + # Ensure recurring wake at 2:55am + if ! pmset -g sched 2>/dev/null | grep -q "2:55"; then + sudo pmset repeat wake MTWRFSU 02:55:00 2>/dev/null \ + || echo "[$(date)] WARNING: pmset repeat wake failed — run: sudo pmset repeat wake MTWRFSU 02:55:00" \ + >> "$PROJECT_DIR/logs/dailyreport.log" + fi fi + +echo "[$(date)] Done" >> "$PROJECT_DIR/logs/dailyreport.log" From 82a08690d87c2b4b3245d435507762c95a51d596 Mon Sep 17 00:00:00 2001 From: Jeff Codling Date: Mon, 11 May 2026 10:08:52 -0400 Subject: [PATCH 02/16] fix: clean up whitespace across all files, fix deploy heredoc Whitespace cleanup: - Remove trailing whitespace from README.md, DOCKER-DEPLOY.md, and scripts - Remove tabs from scripts - Fix run.sh empty line trailing whitespace Deploy script fixes: - Replace broken double-quoted SSH block with heredoc (was passing literal backslashes to docker due to bash quoting rules) - Align helper function definitions - Replace emoji with text for terminal compatibility --- DOCKER-DEPLOY.md | 110 ++++++++++++++++++++++++------------------ docker-compose.yml | 8 +-- scripts/deploy-nas.sh | 94 ++++++++++++++++++------------------ scripts/run.sh | 2 +- 4 files changed, 116 insertions(+), 98 deletions(-) diff --git a/DOCKER-DEPLOY.md b/DOCKER-DEPLOY.md index 51811e9..68e48f1 100644 --- a/DOCKER-DEPLOY.md +++ b/DOCKER-DEPLOY.md @@ -8,17 +8,18 @@ One deployment script, zero NAS CLI knowledge needed: ```bash # One-time setup on your Mac -brew install --cask docker # Docker Desktop CLI -brew install sshpass rsync # NAS communication tools +brew install --cask docker +brew install sshpass rsync # Deploy -export NAS_IP=192.168.1.100 # your NAS IP -export NAS_USER=admin # your NAS username -export NAS_PASS=yourpassword # your NAS password +export NAS_IP=192.168.1.100 +export NAS_USER=admin +export NAS_PASS=yourpassword ./scripts/deploy-nas.sh ``` This script: + 1. Builds the Docker image locally on your Mac 2. Saves and SSH-transfers the image to your NAS 3. Loads the image on the NAS @@ -31,50 +32,50 @@ If you prefer the Container Station GUI instead of the CLI script: 1. **Transfer files via File Station:** - Copy the entire project folder to `/docker/dailyreport/` on your NAS - + 2. **Build image via Container Station:** - - Open **Container Station** → **Created Image** → **Create from URL** + - Open **Container Station** -> **Created Image** -> **Create from URL** - Set URL to local path: `/docker/dailyreport/` - Container Station will find the `Dockerfile` and build - + 3. **Create container:** - - Open **Container Station** → **Container** → **Create** + - Open **Container Station** -> **Container** -> **Create** - Select image: `dailyreport:latest` - Set scheduling: **Daily at 03:00**, task: **Start** - Advanced settings: - - **Volume:** Mount `/volume1/docker/dailyreport/` → `/app` (Read/Write) - - **Environment:** Add one variable `ENV_FILE_PATH` = `/app/.env` - - **Network:** Use `bridge` (default) + - **Volume:** Mount `/volume1/docker/dailyreport/` -> `/app` (Read/Write) + - **Environment:** Add one variable `ENV_FILE_PATH` = `/app/.env` + - **Network:** Use `bridge` (default) - Start the container ## Architecture ``` -┌─────────────────────┐ ┌─────────────────────────────┐ -│ Your Mac │ │ Synology NAS │ -│ │ scp │ │ -│ ./scripts/ │ ────────► │ /volume1/docker/dailyreport│ -│ deploy-nas.sh ─────► │ │ ├── reports/ (persisted) │ -│ │ rsync │ ├── config/ (persisted) │ -│ local docker build │ │ ├── logs/ (persisted) │ -│ + image save │ │ └── src/ (application) │ -│ │ │ │ -│ │ │ Container Station │ -│ │ │ ┌──────────────────────┐ │ -│ │ │ │ dailyreport_daily │ │ -│ │ │ │ oven/bun:1-alpine │ │ -│ │ │ │ bun run generate │ │ -│ │ │ └──────────────────────┘ │ -│ │ │ ▲ │ -│ │ │ scheduled task │ -└─────────────────────┘ └─────────────────────────────┘ - │ - │ SFTP - ▼ - ┌─────────────────┐ - │ IONOS SFTP │ - │ (web UI host) │ - └─────────────────┘ ++-------------------------+ +----------------------------+ +| Your Mac | | Synology NAS | +| | scp | | +| ./scripts/ | ----------> | /volume1/docker/dailyreport| +| deploy-nas.sh -------> | | +-- reports/ (persisted) | +| | rsync | +-- config/ (persisted) | +| local docker build | | +-- logs/ (persisted) | +| + image save | | +-- src/ (application) | +| | | | +| | | Container Station | +| | | +--------------------+ | +| | | | dailyreport_daily | | +| | | | oven/bun:1-alpine | | +| | | | bun run generate | | +| | | +--------------------+ | +| | | ^ | +| | | scheduled task | ++-------------------------+ +----------------------------+ + | + | SFTP + v + +-----------------+ + | IONOS SFTP | + | (web UI host) | + +-----------------+ ``` ## File Persistence @@ -92,15 +93,20 @@ All state persists across container recreations via bind mounts: ## Managing the Container ### View logs + ```bash ssh admin@192.168.1.100 docker logs dailyreport_daily --tail 50 +``` + +Follow in real time: -# Follow in real time: +```bash docker logs -f dailyreport_daily ``` ### View generated reports + ```bash ssh admin@192.168.1.100 docker exec dailyreport_daily ls /app/reports/ @@ -108,28 +114,30 @@ docker exec dailyreport_daily cat /app/reports/2025-05-08.md ``` ### Re-deploy after code changes + ```bash -# From your Mac: export NAS_IP=192.168.1.100 -export NAS_USER=admin +export NAS_USER=admin export NAS_PASS=yourpassword -./scripts/deploy-nas.sh # stops old, builds new, starts fresh container +./scripts/deploy-nas.sh ``` +Stops old, builds new, starts fresh container. ### Manual full rebuild + ```bash ssh admin@192.168.1.100 docker stop dailyreport_daily docker rm dailyreport_daily docker rmi dailyreport:latest -./scripts/deploy-nas.sh # rebuild from scratch +./scripts/deploy-nas.sh ``` ## Scheduling via Container Station (One-Time Setup) The container exits after completing its task. For it to run daily, you need a **Scheduled Task**: -1. Open DSM → **Container Station** +1. Open DSM -> **Container Station** 2. Click **Scheduled Task** in the left sidebar 3. Click **Create** 4. Select container: **dailyreport_daily** @@ -142,14 +150,18 @@ The container will start at 03:00, run for ~3-5 seconds, then exit. Container St ## Troubleshooting ### Container exits immediately with an error + ```bash ssh admin@192.168.1.100 docker logs dailyreport_daily --tail 50 ``` -Common causes: missing `.env` on NAS, wrong SFTP credentials, NAS can't reach the internet. -### Can't reach SFTP host from NAS +Common causes: missing `.env` on NAS, wrong SFTP credentials, NAS cannot reach the internet. + +### Cannot reach SFTP host from NAS + The NAS needs outbound HTTPS and SFTP access: + ```bash ssh admin@192.168.1.100 docker exec dailyreport_daily curl -Is https://google.com @@ -157,24 +169,30 @@ docker exec dailyreport_daily curl -Is https://home554762802.1and1-data.host ``` ### .env not being read by the container + Verify the `.env` file exists on the NAS: + ```bash ssh admin@192.168.1.100 cat /volume1/docker/dailyreport/.env ``` + It must have the FTP variables: `FTP_HOST`, `FTP_USER`, `FTP_PASS`, optionally `TARGET_DIR`. ### Container reports "image not found" + ```bash ssh admin@192.168.1.100 docker images | grep dailyreport ``` + If missing, re-run `./scripts/deploy-nas.sh`. ### Logs show permission errors + The `Dockerfile` creates an `appuser` (UID 1000) with proper ownership. If your NAS has different UID/GID mappings, you may need to adjust the Dockerfile's `USER` directive or set appropriate file permissions on the NAS host directories. -## What's Inside the Container +## What is Inside the Container | Path | Purpose | |---|---| diff --git a/docker-compose.yml b/docker-compose.yml index dcc420a..f50b59f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ # DailyReport — Docker Compose # # For users who prefer `docker-compose up` over the CLI deploy -# script. Not required — the deploy script (scripts/deploy-nas.sh) +# script. Not required — the deploy script (scripts/deploy-nas.sh) # handles everything. # # Usage: @@ -16,11 +16,11 @@ services: context: . dockerfile: Dockerfile container_name: dailyreport_daily - restart: "no" # one-shot — exits after generating report + restart: "no" env_file: - - .env # SFTP and OAuth credentials + - .env volumes: - - dailyreport-data:/app # persists reports, config, logs + - dailyreport-data:/app volumes: dailyreport-data: diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index 0f42a7a..74af622 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -2,18 +2,19 @@ # ============================================================ # deploy-nas.sh — Deploy DailyReport to Synology NAS # -# Method: Build locally → save image → SSH to NAS → load image -# → run container → one-time guide for Container Station scheduling. +# Method: Build locally -> save image -> SSH to NAS -> load image +# -> run container -> one-time guide for Container Station +# scheduling. # # Prerequisites (install once on your Mac): # brew install --cask docker # brew install sshpass rsync jq # # Usage: -# export NAS_IP=192.168.1.100 -# export NAS_USER=admin -# export NAS_PASS=yourpassword -# ./scripts/deploy-nas.sh +# export NAS_IP=192.168.1.100 +# export NAS_USER=admin +# export NAS_PASS=yourpassword +# ./scripts/deploy-nas.sh # # ============================================================ @@ -31,20 +32,20 @@ CONTAINER_NAME="dailyreport_daily" DOCKER_DIR="/docker/dailyreport" # --- Helpers --------------------------------------------------- -info() { echo -e "\033[32m[DEPLOY]\033[0m $*"; } -warn() { echo -e "\033[33m[DEPLOY]\033[0m $*"; } -error() { echo -e "\033[31m[DEPLOY]\033[0m $*" >&2; exit 1; } +info() { echo -e "\033[32m[DEPLOY]\033[0m $*"; } +warn() { echo -e "\033[33m[DEPLOY]\033[0m $*"; } +error() { echo -e "\033[31m[DEPLOY]\033[0m $*" >&2; exit 1; } # --- Pre-flight checks ---------------------------------------- [ -z "$NAS_PASS" ] && error "Set NAS_PASS or export NAS_PASS=your-password" -command -v docker || error "docker not found — run: brew install --cask docker" -command -v sshpass || warn "sshpass not found — run: brew install sshpass" -command -v rsync || warn "rsync not found — run: brew install rsync" +command -v docker || error "docker not found — run: brew install --cask docker" +command -v sshpass || warn "sshpass not found — run: brew install sshpass" +command -v rsync || warn "rsync not found — run: brew install rsync" -# --- Clean up previous deploy state --------------------------- +# --- Clean up previous deploy state -------------------------- info "Cleaning up previous deploy artifacts..." sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ - "docker stop $CONTAINER_NAME 2>/dev/null || true; docker rm $CONTAINER_NAME 2>/dev/null || true" + "docker stop $CONTAINER_NAME 2>/dev/null || true; docker rm $CONTAINER_NAME 2>/dev/null || true" rm -f "$IMAGE_FILE" # --- Step 1: Build image locally ------------------------------ @@ -65,29 +66,29 @@ info "Image transferred to NAS." # --- Step 3: Load image on NAS -------------------------------- info "Loading image on NAS..." sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ - "docker load -i /tmp/dailyreport-image.tar && rm /tmp/dailyreport-image.tar" + "docker load -i /tmp/dailyreport-image.tar && rm /tmp/dailyreport-image.tar" info "Image loaded on NAS: $IMAGE_TAG" # --- Step 4: Prepare host directories on NAS ------------------ info "Preparing storage folders on NAS..." sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ - "mkdir -p /volume1${DOCKER_DIR}/{reports,logs,config}" + "mkdir -p /volume1${DOCKER_DIR}/{reports,logs,config}" # --- Step 5: Copy application source to NAS ------------------- info "Copying application source to NAS..." rsync -avz --progress \ - -e "sshpass -p '$NAS_PASS' ssh -o StrictHostKeyChecking=no" \ - --exclude='.git/' \ - --exclude='node_modules/' \ - --exclude='.DS_Store' \ - --exclude='reports/' \ - --exclude='logs/' \ - --exclude='feedback-historical/' \ - "$PROJECT_DIR/" "${NAS_USER}@${NAS_IP}:/volume1${DOCKER_DIR}/" + -e "sshpass -p '$NAS_PASS' ssh -o StrictHostKeyChecking=no" \ + --exclude='.git/' \ + --exclude='node_modules/' \ + --exclude='.DS_Store' \ + --exclude='reports/' \ + --exclude='logs/' \ + --exclude='feedback-historical/' \ + "$PROJECT_DIR/" "${NAS_USER}@${NAS_IP}:/volume1${DOCKER_DIR}/" # Copy .env separately (excluded by rsync .dockerignore pattern) sshpass -p "$NAS_PASS" scp -o StrictHostKeyChecking=no \ - "$PROJECT_DIR/.env" "${NAS_USER}@${NAS_IP}:${DOCKER_DIR}/.env" + "$PROJECT_DIR/.env" "${NAS_USER}@${NAS_IP}:${DOCKER_DIR}/.env" info "Files copied. Starting container..." @@ -97,18 +98,17 @@ info "Files copied. Starting container..." # have quirks with --env-file relative paths). info "Starting container on NAS..." -sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" " +sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" </dev/null || true -docker rm $CONTAINER_NAME 2>/dev/null || true +docker rm $CONTAINER_NAME 2>/dev/null || true docker run -d \ - --name $CONTAINER_NAME \ - --restart no \ - $(if [ -f /volume1${DOCKER_DIR}/.env ]; then echo '--env-file /volume1/docker/dailyreport/.env'; fi) \ - -v /volume1${DOCKER_DIR}:/app \ - $IMAGE_TAG -" - + --name $CONTAINER_NAME \ + --restart no \ + $(if [ -f /volume1${DOCKER_DIR}/.env ]; then echo '--env-file /volume1/docker/dailyreport/.env'; fi) \ + -v /volume1${DOCKER_DIR}:/app \ + $IMAGE_TAG +HEREDOC # --- Step 7: Verify ------------------------------------------- echo "" WAIT=0 @@ -118,14 +118,14 @@ while [ $WAIT -lt 10 ]; do "docker inspect --format '{{.State.Status}}' $CONTAINER_NAME" 2>/dev/null || echo "unknown") if [ "$CONTAINER_STATUS" = "running" ]; then - info "✅ Container is running on NAS!" + info "Container is running on NAS!" echo "" info "Recent container logs:" sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ "docker logs --tail 10 $CONTAINER_NAME" break elif [ "$CONTAINER_STATUS" = "exited" ]; then - warn "⚠ Container exited (expected — it runs then exits after curation). Check this for errors:" + warn "Container exited (expected — it runs then exits after curation). Check this for errors:" sshpass -p "$NAS_PASS" ssh -o StrictHostKeyChecking=no "$NAS_USER@$NAS_IP" \ "docker logs --tail 20 $CONTAINER_NAME" break @@ -136,7 +136,7 @@ while [ $WAIT -lt 10 ]; do sleep 2 done -[ $WAIT -eq 10 ] && warn "⚠ Could not verify container status. Check manually:" +[ $WAIT -eq 10 ] && warn "Could not verify container status. Check manually:" [ $WAIT -eq 10 ] && echo " docker ps -a --filter name=$CONTAINER_NAME" # ====================================================================== @@ -150,21 +150,21 @@ echo "" echo "Container status on NAS ($NAS_IP):" echo " docker ps -a --filter name=$CONTAINER_NAME" echo "" -echo "⚡ IMPORTANT — Schedule the container to run daily:" +echo "IMPORTANT: Schedule the container to run daily:" echo "" -echo " 1. Open DSM → Container Station" -echo " 2. Find the project named \"docker\" containing $CONTAINER_NAME" -echo " 3. Click \"Scheduled Task\" (left sidebar)" -echo " 4. Click \"Create\"" -echo " 5. Container: $CONTAINER_NAME" -echo " 6. Schedule: Every day at 03:00" -echo " 7. Task: Start" -echo " 8. Save" +echo " 1. Open DSM -> Container Station" +echo " 2. Find the project named \"docker\" containing $CONTAINER_NAME" +echo " 3. Click \"Scheduled Task\" (left sidebar)" +echo " 4. Click \"Create\"" +echo " 5. Container: $CONTAINER_NAME" +echo " 6. Schedule: Every day at 03:00" +echo " 7. Task: Start" +echo " 8. Save" echo "" echo "Verify a run:" echo " docker exec ${CONTAINER_NAME} ls /app/reports/" echo " docker logs ${CONTAINER_NAME} --tail 30" echo "" echo "Re-deploy after source changes:" -echo " ./scripts/deploy-nas.sh" +echo " ./scripts/deploy-nas.sh" echo "=============================================================" diff --git a/scripts/run.sh b/scripts/run.sh index 8072714..2915d4b 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -35,7 +35,7 @@ if [ "$IN_CONTAINER" = "1" ]; then else # On macOS: keep the machine awake via caffeine, and wake it the next day caffeinate -i "$HOME/.bun/bin/bun" run "$PROJECT_DIR/src/index.ts" - + # Ensure recurring wake at 2:55am if ! pmset -g sched 2>/dev/null | grep -q "2:55"; then sudo pmset repeat wake MTWRFSU 02:55:00 2>/dev/null \ From 3eec5ba513189f6d8ff43ffd0fc1e5e2c864c582 Mon Sep 17 00:00:00 2001 From: Jeff Codling Date: Mon, 11 May 2026 10:11:02 -0400 Subject: [PATCH 03/16] chore: add .editorconfig and pre-commit hook for whitespace checking --- .editorconfig | 41 +++++++++++++++++++ scripts/pre-commit.hook | 90 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 .editorconfig create mode 100644 scripts/pre-commit.hook diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1f41b79 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +# Editor configuration — keeps whitespace consistent across all tools + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{md,txt}] +indent_size = 4 + +[*.sh] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 + +[*.yaml] +indent_style = space +indent_size = 2 + +[*.{json,jsonc}] +indent_style = space +indent_size = 2 + +[*.ts] +indent_style = space +indent_size = 2 + +[*.js] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/scripts/pre-commit.hook b/scripts/pre-commit.hook new file mode 100644 index 0000000..53cf905 --- /dev/null +++ b/scripts/pre-commit.hook @@ -0,0 +1,90 @@ +#!/bin/bash +# +# Git pre-commit hook — blocks commits with trailing whitespace +# or invalid bash syntax. +# +# Intended file location: .git/hooks/pre-commit +# Source copy lives in: scripts/pre-commit.hook +# +# Checks: +# 1. Trailing whitespace on staged text files +# 2. Tab characters in non-Makefile files +# 3. Bash syntax validation on .sh files +# 4. Docker lint (optional, if hadolint is installed) +# + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' + +error_count=0 + +# --- Step 1: Trailing whitespace on staged files --------------- +staged_files=$(git diff --cached --name-only --diff-filter=ACM) + +if [ -n "$staged_files" ]; then + while IFS= read -r file; do + # Skip binary files + if file --mime "$file" 2>/dev/null | grep -q 'charset=binary'; then + continue + fi + + # Check for trailing whitespace + result=$(git diff --cached --check -- "$file" 2>&1) + if [ -n "$result" ]; then + echo -e "${RED}FAIL:${NC} Trailing whitespace in ${file}" + echo "$result" | sed 's/^/ /' + error_count=$((error_count + 1)) + fi + + # Check for tabs (skip Makefiles — they require tabs) + if [[ "$file" != Makefile ]]; then + if git grep --cached -P '\t' -- "$file" >/dev/null 2>&1; then + tab_count=$(git grep --cached -Pn '\t' -- "$file" 2>/dev/null | wc -l | tr -d ' ') + echo -e "${YELLOW}WARN:${NC} ${tab_count} tab(s) in ${file} (use spaces)" + fi + fi + done <<< "$staged_files" +fi + +# --- Step 2: Bash syntax check -------------------------------- +sh_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.sh$') + +if [ -n "$sh_files" ]; then + while IFS= read -r file; do + if [ ! -f "$file" ]; then + continue + fi + if bash -n "$file" >/dev/null 2>&1; then + echo -e "${GREEN} OK:${NC} ${file} (bash syntax)" + else + echo -e "${RED}FAIL:${NC} bash syntax error in ${file}" + bash -n "$file" 2>&1 | sed 's/^/ /' + error_count=$((error_count + 1)) + fi + done <<< "$sh_files" +fi + +# --- Step 3: Docker lint (optional) --------------------------- +docker_files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '(^Dockerfile$|^Dockerfile\.\w+$|docker-compose.*\.ya?ml$)') + +if [ -n "$docker_files" ] && command -v hadolint >/dev/null 2>&1; then + while IFS= read -r file; do + if hadolint "$file" 2>&1 | grep -q 'ERROR'; then + echo -e "${RED}FAIL:${NC} Docker lint issues in ${file}" + else + echo -e "${GREEN} OK:${NC} ${file} (Docker lint)" + fi + done <<< "$docker_files" +fi + +# --- Result --------------------------------------------------- +echo "" +if [ "$error_count" -gt 0 ]; then + echo -e "${RED}Commit blocked — fix the $error_count error(s) above, then try again.${NC}" + exit 1 +fi + +echo -e "${GREEN}All checks passed.${NC}" +exit 0 From 00e1268596703b77ef560fcee79fd833b07c55c1 Mon Sep 17 00:00:00 2001 From: Jeff Codling Date: Mon, 11 May 2026 10:11:20 -0400 Subject: [PATCH 04/16] fix: remove trailing whitespace from JSON config files --- config/blacklist.json | 2 +- config/feedback-weights.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/blacklist.json b/config/blacklist.json index fa3ac9c..e115554 100644 --- a/config/blacklist.json +++ b/config/blacklist.json @@ -7,4 +7,4 @@ "foreignpolicy.com", "blogsicilia.it", "foreignaffairs.com" -] \ No newline at end of file +] diff --git a/config/feedback-weights.json b/config/feedback-weights.json index ada59fc..be1e601 100644 --- a/config/feedback-weights.json +++ b/config/feedback-weights.json @@ -725,4 +725,4 @@ "forced": 0.28, "admit": 0.28, "biden": 0.28 -} \ No newline at end of file +} From a81d6eb94b2f5a455d2c0d9efd7008f223a820ff Mon Sep 17 00:00:00 2001 From: Jeff Codling Date: Mon, 11 May 2026 10:11:34 -0400 Subject: [PATCH 05/16] fix: use heredoc to fix multi-line docker run in deploy script, add lint-fix.sh utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace broken double-quoted SSH block with proper heredoc in deploy-nas.sh so bash variable expansion works correctly - Add scripts/lint-fix.sh — runs trailing-whitespace cleanup across all tracked files. Use: ./scripts/lint-fix.sh --dry-run to preview ./scripts/lint-fix.sh to apply fixes --- scripts/lint-fix.sh | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100755 scripts/lint-fix.sh diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh new file mode 100755 index 0000000..9a63afa --- /dev/null +++ b/scripts/lint-fix.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# +# lint-fix.sh — remove trailing whitespace and ensure final newline +# across all tracked text files. +# +# Usage: ./scripts/lint-fix.sh [--dry-run] +# ./scripts/lint-fix.sh --all also scans untracked text files +# + +set -euo pipefail + +DRY_RUN=0 +SCAN_ALL=0 + +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 +fi + +if [ "${2:-}" = "--all" ]; then + SCAN_ALL=1 +fi + +changed=0 + +# --- Get list of files ---------------------------------------- +if [ "$SCAN_ALL" = "1" ]; then + files=$(git ls-files -z --no-exclude-standard 2>/dev/null | tr '\0' '\n') +else + files=$(git ls-files -z 2>/dev/null | tr '\0' '\n') +fi + +if [ -z "$files" ]; then + echo "No files to check." + exit 0 +fi + +# --- Process each file ---------------------------------------- +while IFS= read -r file; do + # Skip binary files + if file --mime "$file" 2>/dev/null | grep -q 'charset=binary'; then + continue + fi + + needs_fix=0 + + # Check trailing whitespace + if grep -Pn '\s+$' "$file" >/dev/null 2>&1; then + needs_fix=1 + fi + + # Check missing final newline + if [ -s "$file" ] && [ "$(tail -c 1 "$file" | wc -l)" -eq 0 ]; then + needs_fix=1 + fi + + if [ "$needs_fix" -eq 1 ]; then + if [ "$DRY_RUN" -eq 1 ]; then + echo "[DRY-RUN] $file" + else + # Fix trailing whitespace: remove whitespace at end of each line + sed -i '' 's/[[:space:]]*$//' "$file" + # Ensure final newline + if [ -s "$file" ] && [ "$(tail -c 1 "$file" | wc -l)" -eq 0 ]; then + echo "" >> "$file" + fi + echo "[FIXED] $file" + fi + changed=$((changed + 1)) + fi +done <<< "$files" + +echo "" +echo "Files changed: $changed" +if [ "$DRY_RUN" -eq 0 ]; then + echo "All trailing whitespace removed. Add the files and commit again." +fi From c9decdb04b1dc769bf20b223a0f6cbfe0f2f15b6 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:36:13 -0400 Subject: [PATCH 06/16] fix(deploy): use correct default-value syntax for NAS_PASS Replace ${NAS_PASS:""} with ${NAS_PASS:-} so that an unset NAS_PASS is caught by the existing error check rather than causing an immediate unbound-variable crash under set -u. --- scripts/deploy-nas.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index 74af622..c9bdd97 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -22,7 +22,7 @@ set -euo pipefail NAS_IP="${NAS_IP:-"192.168.1.100"}" NAS_USER="${NAS_USER:-"admin"}" -NAS_PASS="${NAS_PASS:""}" +NAS_PASS="${NAS_PASS:-}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" From 995ca44003e1cc542ab9bbe5f6684b0d0cb840f3 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:37:46 -0400 Subject: [PATCH 07/16] fix(deploy): add sshpass to scp so image transfer doesn't hang The scp call that transfers the built image to the NAS was missing sshpass, causing an interactive password prompt that breaks automation. Consistent with all other SSH/scp calls in the script. --- scripts/deploy-nas.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index c9bdd97..c2ac23f 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -59,7 +59,7 @@ info "Image built: $IMAGE_TAG ($(( IMG_SIZE / 1024 / 1024 ))MB)" # --- Step 2: Save image and transfer to NAS ------------------- info "Saving and transferring image to NAS ($NAS_IP)..." docker save -o "$IMAGE_FILE" "$IMAGE_TAG" -scp "$IMAGE_FILE" "${NAS_USER}@${NAS_IP}:/tmp/" +sshpass -p "$NAS_PASS" scp -o StrictHostKeyChecking=no "$IMAGE_FILE" "${NAS_USER}@${NAS_IP}:/tmp/" rm -f "$IMAGE_FILE" info "Image transferred to NAS." From 2752503e3dc0315a70cdc24615afd3ed95ed072a Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:40:53 -0400 Subject: [PATCH 08/16] fix(deploy): always pass --env-file directly instead of local conditional The $(if [ -f ... ]) was evaluated on the Mac, not on the NAS, so the --env-file flag was never passed and the container started without environment variables. Replaced with a direct --env-file flag using the consistent DOCKER_DIR path. --- scripts/deploy-nas.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index c2ac23f..6666263 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -105,7 +105,7 @@ docker rm $CONTAINER_NAME 2>/dev/null || true docker run -d \ --name $CONTAINER_NAME \ --restart no \ - $(if [ -f /volume1${DOCKER_DIR}/.env ]; then echo '--env-file /volume1/docker/dailyreport/.env'; fi) \ + --env-file /volume1${DOCKER_DIR}/.env \ -v /volume1${DOCKER_DIR}:/app \ $IMAGE_TAG HEREDOC From 9e23b6c1e02e8fa066ef089a0aba46ee7ec87aa2 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:41:28 -0400 Subject: [PATCH 09/16] fix(deploy): copy .env to correct NAS path (/volume1 + DOCKER_DIR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scp destination was ${DOCKER_DIR}/.env which resolves to /docker/dailyreport/.env — missing the /volume1 prefix that all other NAS paths use. This meant the .env land'd one directory too high for the container's volume mount to find it. --- scripts/deploy-nas.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index 6666263..a40c6d5 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -88,7 +88,7 @@ rsync -avz --progress \ # Copy .env separately (excluded by rsync .dockerignore pattern) sshpass -p "$NAS_PASS" scp -o StrictHostKeyChecking=no \ - "$PROJECT_DIR/.env" "${NAS_USER}@${NAS_IP}:${DOCKER_DIR}/.env" + "$PROJECT_DIR/.env" "${NAS_USER}@${NAS_IP}:/volume1${DOCKER_DIR}/.env" info "Files copied. Starting container..." From 215eaea11793fcef1d0a36ce771b8d78ecce1080 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:42:27 -0400 Subject: [PATCH 10/16] fix(lint): use POSIX grep instead of grep -P for macOS compatibility macOS ships BSD grep which doesn't support -P (Perl regex). Replace with grep -En and [[:space:]] character class which works on both macOS and Linux. --- scripts/lint-fix.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh index 9a63afa..8720dac 100755 --- a/scripts/lint-fix.sh +++ b/scripts/lint-fix.sh @@ -44,7 +44,7 @@ while IFS= read -r file; do needs_fix=0 # Check trailing whitespace - if grep -Pn '\s+$' "$file" >/dev/null 2>&1; then + if grep -En '[[:space:]]+$' "$file" >/dev/null 2>&1; then needs_fix=1 fi From c46ddb2fa698e2573a6b575a13c6a62f72b4e56e Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:43:20 -0400 Subject: [PATCH 11/16] fix(lint): remove unused -n flag from grep call --- scripts/lint-fix.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh index 8720dac..be9cc34 100755 --- a/scripts/lint-fix.sh +++ b/scripts/lint-fix.sh @@ -44,7 +44,7 @@ while IFS= read -r file; do needs_fix=0 # Check trailing whitespace - if grep -En '[[:space:]]+$' "$file" >/dev/null 2>&1; then + if grep -E '[[:space:]]+$' "$file" >/dev/null 2>&1; then needs_fix=1 fi From ee7ab65b1ac6d5b75299d9ee734b6a70fec69afa Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:51:55 -0400 Subject: [PATCH 12/16] fix(lint): replace positional args with case loop + fix --exclude-standard flag Replace fragile positional $1/$2 flag parsing with a for/case loop so --dry-run and --all can be passed in any order and combined. Also fix --no-exclude-standard (invalid git option) to --exclude-standard (the correct flag). Fixes pre-existing bug that made --all silently do nothing on both macOS and Linux. --- scripts/lint-fix.sh | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh index be9cc34..3561f8d 100755 --- a/scripts/lint-fix.sh +++ b/scripts/lint-fix.sh @@ -12,19 +12,25 @@ set -euo pipefail DRY_RUN=0 SCAN_ALL=0 -if [ "${1:-}" = "--dry-run" ]; then - DRY_RUN=1 -fi - -if [ "${2:-}" = "--all" ]; then - SCAN_ALL=1 -fi +for arg in "$@"; do + case "$arg" in + --dry-run) + DRY_RUN=1 + ;; + --all) + SCAN_ALL=1 + ;; + *) + echo "Unknown option: $arg" >&2 + ;; + esac +done changed=0 # --- Get list of files ---------------------------------------- if [ "$SCAN_ALL" = "1" ]; then - files=$(git ls-files -z --no-exclude-standard 2>/dev/null | tr '\0' '\n') + files=$(git ls-files -z --exclude-standard 2>/dev/null | tr '\0' '\n') else files=$(git ls-files -z 2>/dev/null | tr '\0' '\n') fi From 21c5da7913f990e3a3a0f775f8fa1eb8182a9757 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:54:10 -0400 Subject: [PATCH 13/16] fix(doc): replace docker exec with direct NAS commands for exited container docker exec fails on exited containers since the container runs ~5s then exits. Replace both the 'View generated reports' and 'Cannot reach SFTP host' sections with direct NAS filesystem/curl commands. --- DOCKER-DEPLOY.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DOCKER-DEPLOY.md b/DOCKER-DEPLOY.md index 68e48f1..fb03ea1 100644 --- a/DOCKER-DEPLOY.md +++ b/DOCKER-DEPLOY.md @@ -109,8 +109,8 @@ docker logs -f dailyreport_daily ```bash ssh admin@192.168.1.100 -docker exec dailyreport_daily ls /app/reports/ -docker exec dailyreport_daily cat /app/reports/2025-05-08.md +ls /volume1/docker/dailyreport/reports/ +cat /volume1/docker/dailyreport/reports/2025-05-08.md ``` ### Re-deploy after code changes @@ -164,8 +164,8 @@ The NAS needs outbound HTTPS and SFTP access: ```bash ssh admin@192.168.1.100 -docker exec dailyreport_daily curl -Is https://google.com -docker exec dailyreport_daily curl -Is https://home554762802.1and1-data.host +curl -Is https://google.com +curl -Is https://home554762802.1and1-data.host ``` ### .env not being read by the container From b0ca737d5f9378745b78e97804df36efd95173ed Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:55:52 -0400 Subject: [PATCH 14/16] fix(docker-compose): add comment explaining named volume is for dev only Named volumes aren't accessible from Synology File Station. This note clarifies that NAS deployments use bind mounts instead. --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index f50b59f..9d66a6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,9 @@ services: env_file: - .env volumes: + # Note: This named volume is for local development only. + # NAS deployments use a bind mount rather than a named volume, + # so that the storage directory is accessible from Synology File Station. - dailyreport-data:/app volumes: From 12e5cf8d089d620d4fa70b2eec48f89706d5f069 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 10:56:40 -0400 Subject: [PATCH 15/16] fix(dockerfile): pin bun install with --frozen-lockfile Prevents silent dependency drift between deploys by ensuring the image reproduces exactly from bun.lock. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2e9dfff..e5b23a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ WORKDIR /app # --- Dependency install (layer-cached: changes when these change) --- COPY package.json bun.lock ./ -RUN bun install --production +RUN bun install --frozen-lockfile --production # --- Application source --- COPY . . From 06d21436ac19961ad8afb20b0d6c4c476e3b6801 Mon Sep 17 00:00:00 2001 From: jeffcodling Date: Mon, 11 May 2026 11:12:49 -0400 Subject: [PATCH 16/16] fix: address all remaining PR review items - deploy-nas.sh: replace docker exec with ssh in next-steps output (container exits after run) - lint-fix.sh: add --others to --all flag so it actually scans untracked files - lint-fix.sh: exit on unknown option to prevent silent fallback to default behavior - pre-commit.hook: replace git grep -P with -F for macOS portability --- scripts/deploy-nas.sh | 2 +- scripts/lint-fix.sh | 3 ++- scripts/pre-commit.hook | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index a40c6d5..4f9206c 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -162,7 +162,7 @@ echo " 7. Task: Start" echo " 8. Save" echo "" echo "Verify a run:" -echo " docker exec ${CONTAINER_NAME} ls /app/reports/" +echo " ssh ${NAS_USER}@${NAS_IP} ls /volume1${DOCKER_DIR}/reports/" echo " docker logs ${CONTAINER_NAME} --tail 30" echo "" echo "Re-deploy after source changes:" diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh index 3561f8d..019ca26 100755 --- a/scripts/lint-fix.sh +++ b/scripts/lint-fix.sh @@ -22,6 +22,7 @@ for arg in "$@"; do ;; *) echo "Unknown option: $arg" >&2 + exit 1 ;; esac done @@ -30,7 +31,7 @@ changed=0 # --- Get list of files ---------------------------------------- if [ "$SCAN_ALL" = "1" ]; then - files=$(git ls-files -z --exclude-standard 2>/dev/null | tr '\0' '\n') + files=$(git ls-files -z --others --exclude-standard 2>/dev/null | tr '\0' '\n') else files=$(git ls-files -z 2>/dev/null | tr '\0' '\n') fi diff --git a/scripts/pre-commit.hook b/scripts/pre-commit.hook index 53cf905..0b4bf28 100644 --- a/scripts/pre-commit.hook +++ b/scripts/pre-commit.hook @@ -40,8 +40,8 @@ if [ -n "$staged_files" ]; then # Check for tabs (skip Makefiles — they require tabs) if [[ "$file" != Makefile ]]; then - if git grep --cached -P '\t' -- "$file" >/dev/null 2>&1; then - tab_count=$(git grep --cached -Pn '\t' -- "$file" 2>/dev/null | wc -l | tr -d ' ') + if git grep --cached -F $'\t' -- "$file" >/dev/null 2>&1; then + tab_count=$(git grep --cached -F -n $'\t' -- "$file" 2>/dev/null | wc -l | tr -d ' ') echo -e "${YELLOW}WARN:${NC} ${tab_count} tab(s) in ${file} (use spaces)" fi fi