#!/bin/sh # Run the whole site locally, in the same shape as production. # # tools/dev.sh build both products and serve on :8080 # tools/dev.sh --no-build use whatever is already in bin/ # # Why this exists: catcrafts-server serves PAGES only. Static assets — the wasm # module, styles.css, the JS bridges — are Caddy's job in production, so running # the server on its own gives you correct HTML with no stylesheet, which looks # broken and isn't. This starts both and puts Caddy in front, so what you see # locally is what the deployed site does, including the reverse-proxy split and # the scoped cross-origin headers. # # Ctrl-C stops both. set -eu BUILD=1 [ "${1:-}" = "--no-build" ] && BUILD=0 PORT="${DEV_PORT:-8080}" BACKEND_PORT="${DEV_BACKEND_PORT:-8081}" WORK="$(mktemp -d)" # Refuse to start if either port is taken. # # Without this, a leftover instance from an earlier run keeps serving: the new # backend fails to bind, the old Caddy carries on proxying to the OLD binary, and # the site looks like the build did not take effect. That has wasted real time # twice — the symptom (stale content) points at the build, not at a process. port_busy() { if command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | grep -qE "[:.]$1 " else curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$1/" 2>/dev/null fi } for _p in "$PORT" "$BACKEND_PORT"; do if port_busy "$_p"; then echo "dev: port $_p is already in use — another instance is probably still running." >&2 echo "dev: find it with: ss -ltnp | grep -E ':(8080|8081) '" >&2 echo "dev: then kill those PIDs, or set DEV_PORT / DEV_BACKEND_PORT." >&2 exit 1 fi done cleanup() { [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true [ -n "${CADDY_PID:-}" ] && kill "$CADDY_PID" 2>/dev/null || true rm -rf "$WORK" } trap cleanup EXIT INT TERM # Exactly one match or fail loudly. A variant directory name embeds a config # hash, so two matches means the tree holds artifacts from two different # configurations and picking either would be a coin flip — this has caused real # confusion (testing a stale binary and believing the result). onedir() { _m=$(find bin -maxdepth 1 -type d -name "$1" 2>/dev/null | sort) _n=$(printf '%s\n' "$_m" | grep -c . || true) if [ "$_n" -ne 1 ]; then echo "dev: expected exactly one $1 directory under bin/, found $_n" >&2 [ "$_n" -gt 1 ] && printf '%s\n' "$_m" >&2 echo "dev: run 'rm -rf bin' and try again" >&2 exit 1 fi printf '%s' "$_m" } if [ "$BUILD" = 1 ]; then echo "dev: building the server product..." crafter-build --product=server >"$WORK/build-server.log" 2>&1 \ || { echo "dev: server build failed:" >&2; tail -20 "$WORK/build-server.log" >&2; exit 1; } SRV=$(onedir 'Catcrafts.Server-*') # sitemap.xml and feed.xml are generated from the same route table and Post # model the pages use, and the wasm build copies them into the bundle — so # they have to exist before it runs. "$SRV/catcrafts-server" --sitemap > sitemap.xml "$SRV/catcrafts-server" --feed > feed.xml echo "dev: building the wasm bundle..." crafter-build >"$WORK/build-web.log" 2>&1 \ || { echo "dev: wasm build failed:" >&2; tail -20 "$WORK/build-web.log" >&2; exit 1; } fi SRV=$(onedir 'Catcrafts.Server-*') WEB=$(onedir 'Catcrafts.Net-*') # Makes the static shell survive being served at a deep URL. Run unconditionally, # not just after a build: --no-build may be pointing at a bundle someone produced # with a bare crafter-build, and the script is idempotent. ./tools/fix-bundle-depth.sh "$WEB" >/dev/null ABS_WEB="$(cd "$WEB" && pwd)" # Mirrored post media. Absent is fine — the pages render, the media 404s — so # this does not block running the site before fetch-media.sh has been run. mkdir -p media PWD_MEDIA="$(cd media && pwd)" # Mirrors deploy/Caddyfile.example: static assets from disk, everything else # proxied to the backend, cross-origin isolation only on the paths that boot the # wasm module. cat > "$WORK/Caddyfile" <&2 echo "dev: live keys belong in /etc/catcrafts/payments.env on the server." >&2 exit 1 ;; *) echo "dev: MOLLIE_API_KEY is not a test_ or live_ key" >&2; exit 1 ;; esac fi # /financials has two inputs and a fresh dev run has neither: sales fold out of # the order ledger, donations and expenses come from the bank-aggregates file. # So the page honestly renders its empty state — which is worth previewing too, # and is why this is opt-in rather than always on. # # DEV_FINANCIALS=1 seeds both with obviously-sample figures, so the FILLED # layout can be designed against without waiting for real money or touching # production. The aggregates file is re-read on every request, so you can edit # it while the server runs and just refresh. # # DEV_FINANCIALS= instead previews a REAL aggregates file — the one # staged for production, say. No sample orders are seeded in that case: sales # fold from the ledger, and inventing them would misrepresent the very figures # you are checking. A closed shop showing €0 of sales is the truth. if [ "${DEV_FINANCIALS:-0}" != 0 ] && [ "${DEV_FINANCIALS:-0}" != 1 ]; then if [ ! -f "$DEV_FINANCIALS" ]; then echo "dev: DEV_FINANCIALS='$DEV_FINANCIALS' is not a file" >&2 exit 1 fi cp "$DEV_FINANCIALS" "$WORK/orders.jsonl.financials.json" echo "dev: previewing REAL financials from $DEV_FINANCIALS (no sample orders seeded)" elif [ "${DEV_FINANCIALS:-0}" = 1 ]; then # Labels say SAMPLE and the amounts are flat round numbers on purpose: # plausible-looking figures here were once mistaken for real bank data. # Nothing in this block comes from anywhere — it exists to fill the layout. cat > "$WORK/orders.jsonl.financials.json" <<'JSON' {"as_of":"2026-01-01", "donations":{"count":4,"total_minor":10000}, "expenses":[{"label":"SAMPLE hosting","total_minor":10000}, {"label":"SAMPLE insurance","total_minor":20000}, {"label":"SAMPLE inventory","total_minor":300000}, {"label":"SAMPLE payment fees","total_minor":5000}]} JSON # Two paid orders, written straight to the ledger: the product is # coming-soon, so checkout refuses and there is no other way to make the # sales row non-zero. Same event shapes Orders.cpp appends. cat > "$WORK/orders.jsonl" <<'JSON' {"type":"order","at":"2026-08-10T10:00:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","ref":"CC-111111","product":"fp6-pmos","color":"green","quantity":1,"unit_minor":56330,"email":"sample@example.org","name":"Sample Buyer","street":"1 Example St","postal":"1000AA","city":"Amsterdam","country":"NL","goods_minor":56330,"shipping_minor":713,"total_minor":57043,"vat_included":true,"status":"awaiting_payment","pay_choice":"bank","pay_url":"","pay_id":"dev-1"} {"type":"status","at":"2026-08-10T10:04:00Z","id":"1111111111111111aaaaaaaaaaaaaaaa","status":"paid","via":"ideal"} {"type":"order","at":"2026-08-12T14:30:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","ref":"CC-222222","product":"fp6-pmos","color":"white","quantity":1,"unit_minor":65488,"email":"other@example.org","name":"Other Buyer","street":"2 Example Rd","postal":"3000BB","city":"Rotterdam","country":"DE","goods_minor":65488,"shipping_minor":2500,"total_minor":67988,"vat_included":true,"status":"awaiting_payment","pay_choice":"crypto","pay_url":"","pay_id":"dev-2"} {"type":"status","at":"2026-08-12T14:33:00Z","id":"2222222222222222bbbbbbbbbbbbbbbb","status":"paid","via":"bitcoin"} JSON echo "dev: seeded SAMPLE financials (DEV_FINANCIALS=1) — figures are invented" fi "$SRV/catcrafts-server" --serve "$BACKEND_PORT" \ --orders="$WORK/orders.jsonl" --rail="$RAIL" \ --redirect-base="http://localhost:$PORT" >"$WORK/server.log" 2>&1 & SRV_PID=$! caddy run --config "$WORK/Caddyfile" --adapter caddyfile >"$WORK/caddy.log" 2>&1 & CADDY_PID=$! # Wait for the front door rather than sleeping a fixed amount. i=0 while [ "$i" -lt 100 ]; do curl -s -o /dev/null "http://127.0.0.1:$PORT/api/healthz" 2>/dev/null && break i=$((i + 1)); sleep 0.1 done if [ "$i" -ge 100 ]; then echo "dev: did not come up. server log:" >&2; cat "$WORK/server.log" >&2 echo "dev: caddy log:" >&2; cat "$WORK/caddy.log" >&2 exit 1 fi cat < previews a real one";; 1) printf '%s' " (SAMPLE data — invented figures)";; *) printf '%s' " (real figures from $DEV_FINANCIALS)";; esac) /feed.xml Atom feed /media/* mirrored post media (run tools/fetch-media.sh to populate) Orders from this session go to a temp file and are discarded on exit.$([ "${DEV_FINANCIALS:-0}" = 1 ] && printf '%s' " Edit the aggregates and just refresh — they are re-read every request: \$EDITOR $WORK/orders.jsonl.financials.json") Payment rail: $RAIL$([ "$RAIL" = fake ] && printf '%s' " — simulate a customer paying with: touch $WORK/orders.jsonl.fake-paid") Ctrl-C to stop. EOF # Surface backend output as it happens — this is where a render error shows up. tail -f "$WORK/server.log" & wait "$SRV_PID"