2026-08-05 04:18:37 +02:00
#!/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..."
2026-08-14 02:50:58 +02:00
crafter-build --product= server >" $WORK /build-server.log " 2>& 1 \
2026-08-05 04:18:37 +02:00
|| { 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..."
2026-08-14 02:50:58 +02:00
crafter-build >" $WORK /build-web.log " 2>& 1 \
2026-08-05 04:18:37 +02:00
|| { 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 " <<EOF
:$PORT {
root * $ABS_WEB
encode zstd gzip
@isolated path /demos/* /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js /catcrafts-head.js /files.json /variants.json /*.wgsl
header @isolated {
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
Cross-Origin-Resource-Policy "same-origin"
}
@static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js /catcrafts-head.js /files.json /variants.json /styles.css /favicon.svg /robots.txt /*.wgsl /*.jpg /posts.json /rates.json
handle @static {
header Cache-Control "no-store"
file_server
}
handle_path /media/* {
root * $PWD_MEDIA
header Cache-Control "no-store"
file_server
}
handle {
reverse_proxy 127.0.0.1:$BACKEND_PORT
}
}
EOF
# Rail selection for dev:
# * a repo-root .env (gitignored, never committed) is sourced if present —
# put MOLLIE_API_KEY=test_… there to point dev at Mollie's real test mode;
# * DEV_RAIL=fake|mollie overrides the automatic choice;
# * default with no key is the fake rail: full order lifecycle, no network.
# "Pay" an order with: touch $WORK/orders.jsonl.fake-paid
#
# A live_ key is refused outright. Dev creates throwaway orders; pointing them
# at real money collection is never what anyone meant.
if [ -f .env ] ; then
set -a; . ./.env; set +a
fi
RAIL = " ${ DEV_RAIL :- } "
if [ -z " $RAIL " ] ; then
RAIL = fake
[ -n " ${ MOLLIE_API_KEY :- } " ] && RAIL = mollie
fi
if [ " $RAIL " = mollie ] ; then
case " ${ MOLLIE_API_KEY :- } " in
test_*) echo "dev: payments via Mollie TEST mode" ; ;
live_*) echo "dev: refusing to run dev against a LIVE Mollie key." >& 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
2026-08-14 04:14:13 +02:00
# /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=<path> 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
2026-08-05 04:18:37 +02:00
" $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 <<EOF
catcrafts.net is running: http://localhost:$PORT
/ home
/shop product list
/shop/fp6-pmos product page + checkout ( fake payment rail)
/projects the Crafter suite
/posts fediverse posts
/demos demo list; /demos/raytracer loads the wasm
/legal/privacy privacy notice
2026-08-14 04:14:13 +02:00
/financials open financials$( case " ${ DEV_FINANCIALS :- 0 } " in
0) printf '%s' " — empty; DEV_FINANCIALS=1 seeds sample figures,
DEV_FINANCIALS = <file> previews a real one" ;;
1) printf '%s' " (SAMPLE data — invented figures)" ; ;
*) printf '%s' " (real figures from $DEV_FINANCIALS ) " ; ; esac )
2026-08-05 04:18:37 +02:00
/feed.xml Atom feed
/media/* mirrored post media ( run tools/fetch-media.sh to populate)
2026-08-14 04:14:13 +02:00
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" )
2026-08-05 04:18:37 +02:00
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 "