Files
menu/README.md
T
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

175 lines
8.8 KiB
Markdown

# 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
```sh
./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.
```sh
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:
```sh
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
```sh
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:
```sh
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.