#!/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" < 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 < fp6-pmos, fp6plus-pmos, donation (fake payment rail) /projects the Crafter suite /posts fediverse posts /demos demo list; /demos/raytracer loads the wasm /legal/privacy privacy notice /financials open financials$(case "${DEV_FINANCIALS:-0}" in 0) printf '%s' " — empty; DEV_FINANCIALS=1 seeds sample figures, DEV_FINANCIALS= 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")$([ "$RAIL" = transfer ] && printf '%s' " — the order page shows the account details a buyer would transfer to. To simulate the money arriving, append the credit the bank would have reported, using the order's own reference (the CC-… code on the order page): tools/dev-credit.sh $WORK/orders.jsonl CC-XXXXXX 57043 The reconciler picks it up within a minute and the order flips to paid. Reference matching is deliberately forgiving, so try lower case, spaces, or the RF… form to see that they all still settle the same order.") 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"