feat: production deploy to m3u96b — ~/srv/drive layout, ngrok config merge, LibreOffice via 7zz DMG extraction, backup+restore drill verified
Live at https://drive.spboucher.ai (pm2: spbdrive-server + spbdrive-tunnel) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 5 changed files with +43 and −42
modified
README.md
+10 −9
@@ -52,7 +52,7 @@ scripts/ check-headers · inject-headers · check-secrets · reset-password | ||
| 52 | 52 | test/ vitest unit + e2e (upload → thumb → protected share → download) |
| 53 | 53 | ``` |
| 54 | 54 | |
| 55 | −Runtime data lives outside the repo at `/srv/drive` | |
| 55 | +Runtime data lives outside the repo at `~/srv/drive` on m3u96b (macOS seals `/srv`; on Linux use `/srv/drive` — the systemd unit keeps that path) | |
| 56 | 56 | (`files/` CAS blobs · `db/drive.sqlite` · `cache/` thumbs+transcodes · `backups/` · `logs/`). |
| 57 | 57 | |
| 58 | 58 | ## Development |
@@ -74,8 +74,8 @@ rsync -a --exclude node_modules --exclude .git . m3u96b:~/spbdrive-src/ | ||
| 74 | 74 | ssh m3u96b 'SPBDRIVE_BOOTSTRAP_PASSWORD=… bash ~/spbdrive-src/deploy/setup-m3u96b.sh ~/spbdrive-src' |
| 75 | 75 | ``` |
| 76 | 76 | |
| 77 | −The script is idempotent: installs ffmpeg/LibreOffice/poppler/7z/ngrok/pm2, creates `/srv/drive` | |
| 78 | −(700), syncs the app to `/srv/drive/app`, `npm ci --omit=dev`, seeds auth on first boot, starts | |
| 77 | +The script is idempotent: installs ffmpeg/LibreOffice/poppler/7z/ngrok/pm2, creates `~/srv/drive` | |
| 78 | +(700), syncs the app to `~/srv/drive/app`, `npm ci --omit=dev`, seeds auth on first boot, starts | |
| 79 | 79 | `spbdrive-server` + `spbdrive-tunnel` under pm2 (`pm2 save` + `pm2 startup` for reboot survival), |
| 80 | 80 | health-checks `:7430/healthz`, and installs the nightly backup cron (02:30). |
| 81 | 81 | |
@@ -85,7 +85,7 @@ gives you at the spboucher.ai DNS provider. TLS terminates at ngrok; the app tru | ||
| 85 | 85 | |
| 86 | 86 | ## Backups & restore |
| 87 | 87 | |
| 88 | −`deploy/backup.sh` (cron, 02:30) writes `/srv/drive/backups/YYYY-MM-DD/`: | |
| 88 | +`deploy/backup.sh` (cron, 02:30) writes `~/srv/drive/backups/YYYY-MM-DD/`: | |
| 89 | 89 | a SQLite `.backup` snapshot plus an rsync `--link-dest` hardlink copy of the blob store |
| 90 | 90 | (unchanged blobs cost zero bytes). Retention: 14 dailies + 8 Sunday weeklies. Each run verifies |
| 91 | 91 | `PRAGMA integrity_check` and blob counts. |
@@ -94,11 +94,12 @@ a SQLite `.backup` snapshot plus an rsync `--link-dest` hardlink copy of the blo | ||
| 94 | 94 | |
| 95 | 95 | ```bash |
| 96 | 96 | pm2 stop spbdrive-server |
| 97 | −SNAP=/srv/drive/backups/<date> | |
| 98 | −cp "$SNAP/drive.sqlite" /srv/drive/db/drive.sqlite | |
| 99 | −rm -f /srv/drive/db/drive.sqlite-wal /srv/drive/db/drive.sqlite-shm | |
| 100 | −rsync -a --delete "$SNAP/files/" /srv/drive/files/ | |
| 101 | −cp -p "$SNAP/auth.json" "$SNAP/keys.json" /srv/drive/ | |
| 97 | +DRIVE=~/srv/drive | |
| 98 | +SNAP=$DRIVE/backups/<date> | |
| 99 | +cp "$SNAP/drive.sqlite" $DRIVE/db/drive.sqlite | |
| 100 | +rm -f $DRIVE/db/drive.sqlite-wal $DRIVE/db/drive.sqlite-shm | |
| 101 | +rsync -a --delete "$SNAP/files/" $DRIVE/files/ | |
| 102 | +cp -p "$SNAP/auth.json" "$SNAP/keys.json" $DRIVE/ | |
| 102 | 103 | pm2 start spbdrive-server && curl -fsS http://127.0.0.1:7430/healthz |
| 103 | 104 | ``` |
| 104 | 105 | |
modified
deploy/backup.sh
+1 −1
@@ -14,7 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | set -euo pipefail |
| 16 | 16 | |
| 17 | −DATA_DIR=/srv/drive | |
| 17 | +DATA_DIR="$HOME/srv/drive" | |
| 18 | 18 | BACKUP_ROOT="$DATA_DIR/backups" |
| 19 | 19 | TODAY="$(date +%Y-%m-%d)" |
| 20 | 20 | DEST="$BACKUP_ROOT/$TODAY" |
modified
deploy/ecosystem.config.cjs
+10 −19
@@ -10,24 +10,16 @@ | ||
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | −const APP_DIR = '/srv/drive/app'; | |
| 14 | −const DATA_DIR = '/srv/drive'; | |
| 15 | −const LOG_DIR = '/srv/drive/logs'; | |
| 13 | +const os = require('node:os'); | |
| 16 | 14 | |
| 17 | −// deploy/ngrok.yml expands ${NGROK_AUTHTOKEN}; when the env var isn't set, | |
| 18 | −// fall back to the token already stored in the agent's default config. | |
| 19 | −function ngrokAuthtoken() { | |
| 20 | − if (process.env.NGROK_AUTHTOKEN) return process.env.NGROK_AUTHTOKEN; | |
| 21 | − try { | |
| 22 | − const fs = require('node:fs'); | |
| 23 | − const os = require('node:os'); | |
| 24 | − const agentCfg = `${os.homedir()}/Library/Application Support/ngrok/ngrok.yml`; | |
| 25 | − const match = fs.readFileSync(agentCfg, 'utf8').match(/authtoken:\s*(\S+)/); | |
| 26 | − return match ? match[1] : ''; | |
| 27 | − } catch { | |
| 28 | − return ''; | |
| 29 | − } | |
| 30 | −} | |
| 15 | +// macOS seals /srv — the drive lives under $HOME/srv (see setup-m3u96b.sh). | |
| 16 | +const DATA_DIR = `${os.homedir()}/srv/drive`; | |
| 17 | +const APP_DIR = `${DATA_DIR}/app`; | |
| 18 | +const LOG_DIR = `${DATA_DIR}/logs`; | |
| 19 | + | |
| 20 | +// The authtoken lives in the agent's default config (ngrok config add-authtoken); | |
| 21 | +// both configs are passed and merged in order — v3 configs don't expand env vars. | |
| 22 | +const NGROK_AGENT_CFG = `${os.homedir()}/Library/Application Support/ngrok/ngrok.yml`; | |
| 31 | 23 | |
| 32 | 24 | module.exports = { |
| 33 | 25 | apps: [ |
@@ -56,9 +48,8 @@ module.exports = { | ||
| 56 | 48 | name: 'spbdrive-tunnel', |
| 57 | 49 | cwd: APP_DIR, |
| 58 | 50 | script: 'ngrok', |
| 59 | − args: ['start', 'spbdrive', '--config', `${APP_DIR}/deploy/ngrok.yml`], | |
| 51 | + args: ['start', 'spbdrive', '--config', NGROK_AGENT_CFG, '--config', `${APP_DIR}/deploy/ngrok.yml`], | |
| 60 | 52 | interpreter: 'none', |
| 61 | − env: { NGROK_AUTHTOKEN: ngrokAuthtoken() }, | |
| 62 | 53 | autorestart: true, |
| 63 | 54 | restart_delay: 5000, |
| 64 | 55 | out_file: `${LOG_DIR}/tunnel.out.log`, |
modified
deploy/ngrok.yml
+3 −4
@@ -12,14 +12,13 @@ | ||
| 12 | 12 | # 1. Reserve the domain `drive.spboucher.ai` in the ngrok dashboard |
| 13 | 13 | # (Universal Gateway → Domains → New Domain). |
| 14 | 14 | # 2. Add the CNAME record ngrok shows you at the spboucher.ai DNS provider. |
| 15 | −# 3. Export NGROK_AUTHTOKEN (or keep the agent's default authtoken config). | |
| 15 | +# 3. Authtoken: `ngrok config add-authtoken <token>` once on the node — | |
| 16 | +# pm2 passes both the default agent config (token) and this file (endpoint), | |
| 17 | +# merged in order. (ngrok v3 configs do not interpolate env vars.) | |
| 16 | 18 | # TLS terminates at ngrok; the app runs trustProxy and reads x-forwarded-for. |
| 17 | 19 | |
| 18 | 20 | version: 3 |
| 19 | 21 | |
| 20 | −agent: | |
| 21 | − authtoken: ${NGROK_AUTHTOKEN} | |
| 22 | − | |
| 23 | 22 | endpoints: |
| 24 | 23 | - name: spbdrive |
| 25 | 24 | url: https://drive.spboucher.ai |
modified
deploy/setup-m3u96b.sh
+19 −9
@@ -16,8 +16,9 @@ | ||
| 16 | 16 | |
| 17 | 17 | set -euo pipefail |
| 18 | 18 | |
| 19 | −APP_DIR=/srv/drive/app | |
| 20 | −DATA_DIR=/srv/drive | |
| 19 | +# macOS seals /srv (read-only system volume) — the drive lives under $HOME/srv. | |
| 20 | +DATA_DIR="$HOME/srv/drive" | |
| 21 | +APP_DIR="$DATA_DIR/app" | |
| 21 | 22 | REPO_SRC="${1:-$PWD}" |
| 22 | 23 | BREW="$(command -v brew || echo /opt/homebrew/bin/brew)" |
| 23 | 24 | |
@@ -28,9 +29,22 @@ log "Installing system packages (ffmpeg, libreoffice, poppler, 7z, ngrok, pm2)" | ||
| 28 | 29 | "$BREW" list ffmpeg >/dev/null 2>&1 || "$BREW" install ffmpeg |
| 29 | 30 | "$BREW" list poppler >/dev/null 2>&1 || "$BREW" install poppler # pdftotext + pdftoppm |
| 30 | 31 | "$BREW" list sevenzip >/dev/null 2>&1 || "$BREW" install sevenzip # 7z |
| 31 | −if ! "$BREW" list --cask libreoffice >/dev/null 2>&1 && [ ! -d /Applications/LibreOffice.app ]; then | |
| 32 | − # --no-quarantine avoids the hdiutil Gatekeeper prompt on headless installs. | |
| 33 | − "$BREW" install --cask --no-quarantine libreoffice || \ | |
| 32 | +if [ ! -d /Applications/LibreOffice.app ]; then | |
| 33 | + # macOS 26 blocks headless hdiutil attach with a consent prompt, so the cask | |
| 34 | + # can't install over SSH. Fetch the DMG via brew, extract it with 7zz instead. | |
| 35 | + "$BREW" fetch --cask libreoffice >/dev/null || true | |
| 36 | + LO_DMG="$(ls "$HOME"/Library/Caches/Homebrew/downloads/*LibreOffice*.dmg 2>/dev/null | head -1)" | |
| 37 | + if [ -n "$LO_DMG" ]; then | |
| 38 | + LO_TMP="$(mktemp -d)" | |
| 39 | + (cd "$LO_TMP" && 7zz x -y "$LO_DMG" >/dev/null 2>&1) || true | |
| 40 | + LO_APP="$(find "$LO_TMP" -maxdepth 3 -name 'LibreOffice.app' | head -1)" | |
| 41 | + if [ -n "$LO_APP" ]; then | |
| 42 | + ditto "$LO_APP" /Applications/LibreOffice.app | |
| 43 | + xattr -dr com.apple.quarantine /Applications/LibreOffice.app 2>/dev/null || true | |
| 44 | + fi | |
| 45 | + rm -rf "$LO_TMP" | |
| 46 | + fi | |
| 47 | + [ -d /Applications/LibreOffice.app ] || \ | |
| 34 | 48 | echo "⚠ LibreOffice install failed — office previews will fall back to download cards" >&2 |
| 35 | 49 | fi |
| 36 | 50 | command -v ngrok >/dev/null 2>&1 || "$BREW" install ngrok |
@@ -45,10 +59,6 @@ fi | ||
| 45 | 59 | |
| 46 | 60 | # 2 ── Data directories (700) ─────────────────────────────────────────── |
| 47 | 61 | log "Creating $DATA_DIR layout" |
| 48 | −if [ ! -d "$DATA_DIR" ]; then | |
| 49 | − sudo mkdir -p "$DATA_DIR" | |
| 50 | − sudo chown "$(id -un):$(id -gn)" "$DATA_DIR" | |
| 51 | −fi | |
| 52 | 62 | mkdir -p "$DATA_DIR"/{files,db,cache,backups,logs} |
| 53 | 63 | chmod 700 "$DATA_DIR" "$DATA_DIR"/{files,db,cache,backups,logs} |
| 54 | 64 | |
| 55 | 65 | |