Files
tanshu 537a4f1e46 Match the deploy system to hops and greatbear
menu-admin was the odd one out: it built from the working directory with a date
tag and a multi-arch manifest, deployed with `-e tag=`, and kept the Docker-era
container name. The other two apps build from `git archive HEAD` and tag with the
commit, so what runs on the host is exactly what git knows about; build only for
the architecture emily actually runs; and use one slug for the container, its
network and its data directory.

The build is now their three lines: `podman build --platform linux/arm64` from
`git archive HEAD`, tagged `:<commit>` and `:latest`, pushed with `podman push`
rather than a manifest. `deploy.sh` pushes the commit first, then refuses a dirty
tracked tree or a HEAD that is not origin/main — which is what makes the commit
tag trustworthy — takes the same `--force` and `--no-push`, and ends in the same
`-u tanshu ... -e image_tag=<commit>`. Its own lint/typecheck/test gate stays,
but behind `--checks`, so a plain deploy behaves as theirs does.

The registry credential moves into vars/default.yml and the role logs in, pulls
and logs out around it, as their roles do. The image is only pulled at deploy
time, so the unit keeps podman's default `Pull=missing` and a reboot never needs
the registry. This is the same plaintext credential the other two repos carry.

One real bug goes with it: the healthcheck gate ran before the handler that
restarts a changed unit, so a run that only changed the unit checked the stale
container. `meta: flush_handlers` before the gate is the fix the sibling repos
already made.

The container, network and data directory are all menu-<site> now. The dotted
Docker-era name and the NetworkAlias it needed are gone, so `podman logs
menu-<site>` and `systemctl --user status menu-<site>` are the same two commands
across all three apps. The Dockerfile keeps its name for the same reason: podman
reads it as readily as a Containerfile, and the siblings use that name too.
2026-10-16 11:10:59 +05:30

8.8 KiB

menu-admin

Uploads, orders and publishes the customer-facing menus of a restaurant Site as photographed Pages, replacing the scp-a-JPG workflow. One instance per Site; docs/plan.md is the agreed design and CONTEXT.md is the glossary.

Status

Built: Site config, the Menu Set store, the image pipeline, the customer-facing renderer (landing page, Section pages, archived sets, media route) with the approved design, the admin (sign in, create a set, upload and reorder Pages, preview, publish, restore, delete), the verify/repair CLI, the container and the Ansible deployment as rootless Podman Quadlet units on emily, behind the shared Caddy unit. Not yet done: copying each Site's data across from monoco and moving DNS, which needs a human present.

Deploying

./deploy.sh                  # push, build <commit>, deploy every Site to emily
./deploy.sh --no-push        # skip the push (you already pushed)
./deploy.sh --force          # skip the guards
./deploy.sh --checks         # lint, format, typecheck and test first
SITE=greatbear ./deploy.sh   # one Site instead of every Site

The image is built with Podman/Buildah (not Docker) from the committed tree (git archive HEAD) and tagged with its commit id, so what runs on the host is exactly what git knows about; deploy.sh refuses a dirty tracked tree and requires HEAD == origin/main. It is built for linux/arm64 because emily is aarch64, and pushed to registry.tanshu.com/menu-admin.

The Ansible deployment lives in ansible/ in this repo, with one inventory entry per Site. It writes a rootless Podman Quadlet unit per Site and appends the Site's block to the shared Caddyfile, exactly as the sibling hops and greatbear apps do; see ansible/readme.md for how a deploy runs and how to roll one back.

Layout

src/config.ts             Site config: brand, theme, seed Sections, limits
src/storage/sections.ts   Categories: the Site's own list, edited in the admin
src/storage/identity.ts   Name, tagline, icon and display switches, edited in the admin
src/storage/manifest.ts   Manifest type, validation, atomic JSON writes
src/storage/sets.ts       The Menu Set store: create, add Pages, publish, restore, delete
src/storage/validate.ts   Boot validation: fatal problems and warnings
src/images/pipeline.ts    sharp variants: Original, Viewing image, Thumbnail
src/auth.ts               scrypt password hash, session cookie, login throttle, CSRF
src/render/               HTML builder, document shell, the customer pages, the admin
src/routes/public.ts      Landing, Section, archive and media routes
src/routes/admin.ts       Sign in, dashboard, editor, upload, publish, restore, delete
src/server.ts             buildApp (what tests drive) and main (what the container runs)
public/                   Hand-written CSS, ~2KB of progressive-enhancement JS, Archivo
config/                   Per-Site brand, theme, limits and the first category list
test/                     node:test suites
docs/plan.md              The agreed plan
docs/operating.md         Publishing, fixing and rolling back, for whoever operates it
docs/adr/                 Decisions worth remembering
Dockerfile                The image, built by podman
compose.yaml              Local dev: podman compose
deploy.sh                 Build HEAD, push it, deploy it
ansible/                  The deployment: one rootless Quadlet unit per Site

The deployment is in ansible/ here, beside the app it deploys, and is run from that directory by deploy.sh.

Running it locally

Node 24 or newer runs the TypeScript sources directly; there is no build step.

npm install
npm run seed      # placeholder Pages in .dev (wipes .dev/data; for looking at the design)
npm run dev       # http://localhost:3000 against .dev

npm run dev first runs scripts/dev-env.ts, which creates .dev/site.env on first use with the password menu-dev-password (override with DEV_ADMIN_PASSWORD=...) and a random session secret, then starts the server with --watch. Sign in at http://localhost:3000/admin.

The file is gitignored and development only: the dev server runs with SECURE_COOKIES=false over http, and that password is a known constant. On the server the same file is written by scripts/provision-site-env.sh with a hash of the real password and mode 600.

Tests and checks:

npm test                                      # node:test
npm run typecheck                             # tsc --noEmit, scripts/ included
npm run verify                                # integrity of .dev/data
npm run check-site -- http://127.0.0.1:3000   # checks a running site from outside

Linting and formatting

npm run lint          # oxlint
npm run lint:fix
npm run format        # prettier --write
npm run format:check  # what deploy.sh runs

oxlint lints the TypeScript and the JavaScript together. This project's typescript@7 is the native port with no JavaScript compiler API, which every ESLint TypeScript parser needs — typescript-eslint requires a >=4.8.4 <6.1.0 peer and refuses to install — and oxlint parses TypeScript without one, so it is the linter that works here. It runs the correctness category and takes about 20 ms over the whole repo. .oxlintrc.json turns four opinionated rules off, and oxlint's config takes no comments, so the reasoning lives here: no-await-in-loop because the loops that await are sequential on purpose (one photo at a time, memory bounded); unicorn/consistent-function-scoping because the helpers close over their route's dependencies; unicorn/no-array-sort because toSorted() is worth adopting but that is a refactor rather than a bug; and oxc/no-map-spread because clarity beats the micro-optimisation. Delete the line to see what a rule would say. npm run typecheck remains the deeper TypeScript gate (strict, with noUnusedLocals, noUncheckedIndexedAccess, exactOptionalPropertyTypes and erasableSyntaxOnly).

Prettier owns formatting (.prettierrc.json); docs/ is excluded because those are hand-wrapped notes rather than code. embeddedLanguageFormatting is off on purpose: the renderers build HTML inside html tagged template literals, and letting Prettier reflow those would change the markup customers receive.

Dragging Pages is the one interaction that only a browser can check, so there is a harness for it. It needs geckodriver on PATH and a running dev server:

geckodriver --port 4444 &
npm run check-dnd -- <set-id> native          # drags and asserts it persisted
npm run check-dnd -- <set-id> none            # just reports affordances

In VS Code

.vscode/ holds launch configurations and tasks.

Configuration What it does
Dev server (debug) runs src/server.ts with the dev environment, breakpoints live
Dev server (watch, no debugger) npm run dev in a terminal
Debug the open test file runs the file you have open under node --test, with child processes attached so breakpoints inside a test bind
Check the running site prompts for a URL and runs scripts/check-site.ts

Breakpoints land in the .ts files themselves: Node strips types in place, so line numbers are the ones you see. The debug configuration creates .dev/site.env first (preLaunchTask), so a fresh clone serves a working admin immediately.

Tasks (Command Palette → Run Task) cover the same ground: seed, the dev server, tests, typecheck, verify and repair. .dev, .tmp and .npm-cache are hidden from the explorer, search and the file watcher, so a watched server does not restart on its own data writes.

Who can do what

/ and /<section>/ are public. /admin needs the password; a Draft's Pages are served only to a signed-in session, and Originals have no route at all.

Storage model

data/
├── originals/<section>/<set-id>/<page-id>.<hash>.<ext>   # kept, never served
├── drafts/<section>/<set-id>/                            # manifest.json + variants
└── published/<section>/<slug>/                           # manifest.json + variants

The directory decides a Menu Set's state; the manifest carries metadata only. <slug> is the valid-until date, else the publication date. The Current Menu Set for a Section is the published one with the newest publishedAt. Published sets are immutable: changing a menu means publishing a new set.