catcrafts.net/tools/dev.sh
Jorijn van der Graaf df91762271
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s
Replaced mollie
2026-08-20 20:15:47 +02:00

277 lines
13 KiB
Shell
Executable file

#!/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" <<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"
}
rewrite /apple-touch-icon-precomposed.png /apple-touch-icon.png
@static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js /catcrafts-head.js /files.json /variants.json /styles.css /favicon.svg /favicon.ico /apple-touch-icon.png /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;
# * DEV_RAIL=transfer|fake overrides the automatic choice;
# * default is TRANSFER, because that is what production serves. Dev used to
# default to the fake rail, which meant the bank rail's actual page — the
# IBAN, the beneficiary name, the reference, the whole thing a buyer reads
# — could not be looked at locally at all.
#
# The transfer rail needs no credential, so dev can run the REAL rail rather
# than a stand-in: it only needs an account to name. If .env does not name one,
# obviously-fake values are used, and they are labelled as such on purpose —
# never make fixture data look real (a sample financials file was once mistaken
# for the actual books).
#
# Nothing here can collect real money: the transfer rail only RENDERS account
# details, and settlement comes from a credits file this machine writes.
if [ -f .env ]; then
set -a; . ./.env; set +a
fi
RAIL="${DEV_RAIL:-transfer}"
if [ "$RAIL" = transfer ]; then
# Refuse a real-looking IBAN that is not yours to be paid into by mistake?
# No — the opposite risk matters here. These values are only ever RENDERED
# in dev; nothing can be paid. What must not happen is dev quietly showing
# the PRODUCTION account while someone screenshots the page, so when .env
# supplies nothing the placeholders say plainly that they are placeholders.
: "${TRANSFER_IBAN:=NL00DEVB0000000000}"
: "${TRANSFER_BENEFICIARY:=DEV PLACEHOLDER, not a real account}"
: "${TRANSFER_BIC:=DEVBNL2A}"
export TRANSFER_IBAN TRANSFER_BENEFICIARY TRANSFER_BIC
if [ "$TRANSFER_IBAN" = NL00DEVB0000000000 ]; then
echo "dev: bank transfer rail with PLACEHOLDER account details"
echo "dev: put TRANSFER_IBAN / TRANSFER_BENEFICIARY / TRANSFER_BIC in .env to preview the real ones"
else
echo "dev: bank transfer rail, account $TRANSFER_IBAN ($TRANSFER_BENEFICIARY)"
fi
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=<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
"$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/<slug> 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=<file> 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"