Files
hermes-config/skills/self-hosting/shop-pro-quote/references/local-testing.md
T
2026-07-12 10:17:17 -04:00

7.7 KiB

Local Testing — Serving SPQ Builds

Two approaches depending on which version you're testing.

v1: Vanilla JS (standalone HTML files)

Served from /mnt/seagate8tb/Websites/ShopProQuote/ (no build step, just HTML+JS+CSS).

cd /mnt/seagate8tb/Websites/ShopProQuote
python3 -m http.server 4173 --bind 0.0.0.0

v2: React SPA (built with Vite)

Served from /mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist/. Plain http.server breaks login because the built frontend connects to PocketBase at /pb (relative path). The simple server returns 404 for /pb/* requests. You need a Python reverse proxy that:

  1. Serves static files from dist/
  2. Has SPA fallback (all non-file routes → index.html)
  3. Proxies /pb/* → PocketBase at 127.0.0.1:8091/api/*
  4. Sends CORS headers on every response (including OPTIONS preflight)

Template: /tmp/spq-backup-server.py

#!/usr/bin/env python3
"""Serve SPQ dist + proxy /pb to PocketBase with CORS."""
from http.server import HTTPServer, SimpleHTTPRequestHandler
import urllib.request
import os

DIST = '/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist'
PB = 'http://127.0.0.1:8091'

class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=DIST, **kwargs)

    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
        super().end_headers()

    def do_OPTIONS(self):
        self.send_response(204)
        self.end_headers()

    def do_GET(self):
        if self.path.startswith('/pb') or self.path.startswith('/api') or self.path.startswith('/deepseek'):
            self.proxy()
        else:
            full = os.path.join(DIST, self.path.lstrip('/'))
            if not os.path.exists(full) or (os.path.isdir(full) and self.path != '/'):
                self.path = '/index.html'
            super().do_GET()

    def do_POST(self):
        if self.path.startswith('/pb') or self.path.startswith('/api') or self.path.startswith('/deepseek'):
            self.proxy()
        else:
            self.send_error(405)

    def do_PUT(self): self.proxy()
    def do_PATCH(self): self.proxy()
    def do_DELETE(self): self.proxy()

    def proxy(self):
        # PB SDK calls /pb/api/collections/... — path already includes /api/
        # DeepSeek calls go to api.deepseek.com
        if self.path.startswith('/deepseek'):
            url = 'https://api.deepseek.com' + self.path[len('/deepseek'):]
        else:
            url = PB + self.path
            if self.path.startswith('/pb'):
                url = PB + self.path[3:]  # /pb/api/... → /api/... on PB
                if not url.startswith(PB + '/api'):
                    url = PB + '/api' + self.path[3:]
        body = None
        if self.headers.get('Content-Length'):
            body = self.rfile.read(int(self.headers['Content-Length']))
        req = urllib.request.Request(url, data=body, method=self.command)
        for k, v in self.headers.items():
            if k.lower() not in ('host', 'connection', 'origin', 'referer'):
                req.add_header(k, v)
        # Add DeepSeek API key
        if self.path.startswith('/deepseek'):
            with open('/tmp/deepseek_key.txt') as f:
                req.add_header('Authorization', f'Bearer {f.read().strip()}')
        try:
            resp = urllib.request.urlopen(req)
            self.send_response(resp.status)
            for k, v in resp.headers.items():
                if k.lower() not in ('transfer-encoding', 'connection'):
                    self.send_header(k, v)
            self.end_headers()
            self.wfile.write(resp.read())
        except urllib.error.HTTPError as e:
            self.send_response(e.code)
            self.end_headers()
            self.wfile.write(e.read())

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 4173), Handler)
    print('Serving SPQ on http://0.0.0.0:4173 (PB proxy → 8091)')
    server.serve_forever()

Usage

IMPORTANT: The proxy also routes /deepseek/*https://api.deepseek.com/ using the API key from /tmp/deepseek_key.txt. The key is extracted from /etc/nginx/sites-enabled/shopproquote on startup. Without this, AI Write, Generate Priorities, and AI Suggest all fail silently.

# Extract API key and start proxy
python3 -c "
import re
with open('/etc/nginx/sites-enabled/shopproquote') as f:
    m = re.search(r'Bearer\s+(sk-\S+)', f.read())
    if m:
        with open('/tmp/deepseek_key.txt','w') as out:
            out.write(m.group(1).rstrip('\"'))
        print('Key saved')
"

python3 /tmp/spq-backup-server.py &
# Verify
curl -s -o /dev/null -w '%{http_code}' http://localhost:4173/
# → 200
curl -s -X POST http://localhost:4173/pb/collections/users/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"demo@shop.com","password":"test1234"}'
# → {"token":"..."}  (login works through proxy)

# Verify DeepSeek
curl -s -X POST http://localhost:4173/deepseek/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Say hello"}],"temperature":0,"thinking":{"type":"disabled"},"max_tokens":50}'
# → {"choices":[{"message":{"content":"Hello!"}}]}  (AI works)

### Pitfalls

- **No `--bind 0.0.0.0` on plain http.server** → LAN devices get connection refused. The proxy template above binds `0.0.0.0` by default.
- **Missing CORS headers** → browser blocks all PB requests. The proxy adds CORS on every response + handles OPTIONS preflight.
- **Missing SPA fallback** → `/dashboard`, `/quote` etc. return 404 on page refresh. The proxy falls back to `index.html` for non-file paths.
- **PB URL mismatch** — v2's `pocketbase.ts` uses `VITE_PB_URL` env var (defaults to `/pb`). If the build was made with a different URL, adjust the proxy or rebuild.
- **Backup paths vary** — the timestamped backup folder (`ShopProQuote.backup-YYYYMMDD-HHMM`) changes per backup. Update DIST path in the script.
- **Dashboard 400 / Something went wrong** — if the PocketBase quotes collection is missing the created system field, the sort=-created query returns HTTP 400. Fix: use sort=-id instead. Same issue applies to repairOrders collection.

- **AI features return no results** — the /deepseek/ path must be proxied to https://api.deepseek.com/ with the API key from /tmp/deepseek_key.txt. The AI module at src/lib/ai.ts calls /deepseek/v1/chat/completions with model: deepseek-v4-flash and thinking: disabled. Without proxy setup, all AI calls return null.

- **React input focus loss** — if a ServiceRow-like component is defined as a nested function inside its parent, every zustand state update causes a re-render that creates a new function identity. React unmounts/remounts the DOM, killing input focus. Fix: extract to a file-level component wrapped in memo, pass all state via props.

## V2 Feature Coverage

As of 2026-06-27, spq-v2 covers ~90% of v1's feature set. See `references/v1-v2-feature-gap.md` for a detailed comparison table of what's built vs what's still only in v1. Missing PocketBase collections (`repair_orders`, `invoices`, `service_advisors`, `vehicles`) cause error states on some pages — create them via admin UI to unlock full functionality.

For mass porting patterns (parallel delegation), see `references/mass-port-delegation.md`.

## Common Port

Use **4173** for local SPQ testing. Check availability:

```bash
ss -tlnp | grep 4173 || echo "port free"

Kill any existing server: pkill -f "spq-backup-server.py" or pkill -f "python3 -m http.server 4173"