15 Commits
Author SHA1 Message Date
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
tanshu e68f8ab00d Run the Sites as rootless Podman Quadlets, and build with podman
The fleet on emily is rootless Podman with Quadlet units under
~/.config/containers/systemd and state in ~/podman-data, all behind one shared
Caddy unit. This brings the menus in line instead of leaving them the last
Docker deployment. The build moves with them: podman reads a Containerfile as
readily as a Dockerfile, so the name now says which toolchain is real.

The app is one .container and one .network file. Two details carry it.
UserNS=keep-id maps the image's own node account onto the user that owns the
units, so data/ and site.env stay owned by tanshu: plain tar backups, no
subuids. provision-site-env.sh writes site.env with the same mapping rather
than --user, which under rootless Podman would have produced a subuid-owned
file the app could not read. And the Site registers its own network with Caddy
by writing a drop-in beside caddy.container, so Caddy never lists its upstreams
and never changes when a Site is added. The unit keeps the Docker-era container
name for the scripts and muscle memory that look it up, while the Caddyfile
proxies to a dotless NetworkAlias, because a container name with dots in it is
a liability in DNS.

The unit keeps podman's Pull=missing, so a restart or a reboot never depends on
the registry being reachable, while the play pulls explicitly at deploy time.
That is also where a missing `podman login` for the private registry now fails,
with the instruction, rather than as a unit that will not start.

copy-site-data.sh moves a Site's data across, and is not a plain rsync:
/var/lib/menu.* on monoco belongs to uid 1000 while the ssh user there is 1001,
so the read needs sudo; monoco cannot ssh to emily, so the workstation relays
the tar; and the extraction runs as tanshu on emily, which is what makes the
files land owned by the user keep-id maps the app onto. It proves the copy by
comparing a SHA-256 of the whole tree against the source and checking every
file's owner.

Not yet done: the copies themselves and the DNS move, which need a human.
2026-10-16 09:54:52 +05:30
tanshu 11f60c619f No minimum password length, and say how to reset it
The script refused anything under ten characters. That is a policy the owner
should hold, not one the tooling invents; the only rule left is that the password
cannot be empty, since an empty one would let anyone in. Resetting is the same
script with --force, which overwrites the hash and generates a fresh session
secret, signing out anyone who was signed in - now written down in the operating
notes and the deployment readme, including the form that prompts on your own
machine instead of the server.
2026-09-23 00:02:35 +05:30
tanshu a81d96668c Refuse to hang at the password prompt
ssh host bash script gives no terminal, so read -s could neither show the prompt
nor read it, and the script sat there looking frozen. It reads from /dev/tty now
and, when there is no terminal, says so and prints the command that works:
ssh -t <host> bash bash <data-root>. The deployment readme shows the -t form and
uses the real paths.
2026-09-22 23:55:34 +05:30
tanshu 3eb6be6c7d Borrow the running app's image for the password step
The script defaulted to menu-admin:latest and refused to run on monoco, where the
image is registry.tanshu.com/menu-admin:<tag>. It now reads the image off the
container the app is already running, deriving the container name the way the
playbook does, and only asks for an explicit image when there is no container to
borrow one from. The failure message names the exact command either way.
2026-09-22 23:51:46 +05:30
tanshu 1923c80298 Stop the health wait from failing a healthy deploy, and clear a stale address
The first real deploy left the site on 502 and this is why. podman builds an OCI
image by default and drops the Docker HEALTHCHECK instruction, so the app
container has no State.Health at all; the role waited on that field, exhausted
its retries and failed the play, so the caddy role never ran and Caddy kept
proxying menu.hopsngrains.com to the old nginx container that is now gone.

The wait now asks the app itself: docker exec node -e fetch /healthz, which needs
no healthcheck and no DNS. And because the address may still be claimed by the
snippet the retired nginx-menu playbook wrote, the caddy role drops that marker
before writing its own - two blocks for one address make Caddy refuse the config,
and a refused reload quietly leaves the old proxy in place.

check-site also asked for the font the old design used, so it would have failed a
healthy site; it checks one we ship.
2026-09-22 23:44:54 +05:30
tanshu 0667110886 Lint with oxlint instead of ESLint
ESLint cannot parse this project's TypeScript: typescript@7 is the native port
with no JavaScript compiler API, which every ESLint TypeScript parser needs, and
typescript-eslint refuses to install with a >=4.8.4 <6.1.0 peer. oxlint parses
TypeScript without the compiler API, so it lints the .ts and the .js together in
about 20ms over the whole repo, and it runs the correctness category as errors.

It found one real thing on the first run: the drag harness built its fetch
options with a body key even for GET. The four opinionated rules it also flagged
are off with their reasoning in the README, since oxlint's config takes no
comments. lint is back in deploy.sh's checks, and the editor has lint tasks and
the oxc extension recommendation. Prettier is unchanged.
2026-09-22 23:21:19 +05:30
tanshu e64f65642f Add ESLint and Prettier
Prettier owns formatting: .prettierrc.json, format and format:check scripts, and
a one-off pass over the repo so the check is green from the start. Embedded
language formatting is off deliberately - the renderers build HTML in html
tagged templates and letting Prettier reflow those would change the markup
customers receive. docs/ is excluded because it is hand-wrapped prose, and shell
scripts have no Prettier parser.

ESLint covers the JavaScript: the two browser scripts, the build and check
scripts, and its own config. no-var is scoped out for the browser scripts on
purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only
show up in a browser.

ESLint cannot lint the TypeScript here: typescript@7 is the native port with no
JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it
refuses to install and forcing it through would leave a parser that cannot read
the compiler. tsc --noEmit stays the TypeScript gate; README records the two
ways to change that.

lint and format:check now run in deploy.sh, and VS Code formats on save with
Prettier and surfaces ESLint findings.
2026-09-22 23:15:08 +05:30
tanshu c31b73d553 Let the owner manage categories, and give the menus a shelf
Categories were baked into config/<site>.site.json, so adding one meant a
rebuild and a redeploy. They are now data: data/sections.json, seeded from the
config the first time, and managed in the admin (add, rename, reorder, remove).
A category's web address is fixed when it is created because that is what a QR
code points at, and a category cannot be removed while it still holds menus.

The landing and the top bar were the ugliest parts. The landing was a column of
hairlines with tiny meta — a broadsheet, and the paper that makes this app worth
looking at was nowhere on it. It is now a shelf: each category is a row whose
right edge is a sliver of that menu's first page, bleeding off the screen, with
the shelf edge in brass and the name in heavy tight Archivo. The bar was a row
of teal pills that would not survive more categories: it now names the menu you
are reading and opens a sheet listing the rest, each with its own paper sliver,
and the current one is the only teal thing on screen. The sheet is a link to the
landing page, so navigating still works without the script.

Also fixes the category manager's own layout, which I shipped unstyled, and the
markup contract the route tests described.
2026-09-22 22:24:53 +05:30
tanshu 7bf67cc299 Make reordering work on the page you are actually looking at
Two defects, both found by driving the real editor in Firefox:

1. The editor drew grab handles with a grab cursor on sets it refuses to
   reorder, so the live Food menu looked draggable and did nothing. Handles and
   the move buttons are now rendered only when reordering is possible, and the
   notice says what can still be done.

2. Reordering was saved only from dragend. Browsers suppress mouse events during
   a drag and dragend is not guaranteed to arrive, which left the rows visibly
   reordered while the server kept the old order — the drag looked like it
   worked and then reverted on reload. The order now commits from dragover after
   a pause in the drag, with drop/dragend/mouse events as faster paths, deduped
   against the last confirmed order and reported in the page.

To make (1) coherent the Current set may now be reordered while its Pages stay
fixed: order is presentation, media URLs are content-hashed and pages are
rendered per request, so nothing stale can be served (ADR 0006). Archives stay
frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like
this one is not masked by a stale admin.js.

scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it
is the only automated check that reaches this path.
2026-09-22 22:01:52 +05:30
tanshu 1f41398370 Make dev mode one command and add VS Code config
npm run dev now runs scripts/dev-env.ts first, which creates .dev/site.env with
a known dev password and a random session secret, so a fresh clone serves a
working admin without anyone hand-building a hash. Adds .vscode launch
configurations and tasks for the dev server, one test file, and check-site, and
brings scripts/ under typecheck, which it had been missing.
2026-09-22 21:47:11 +05:30
tanshu 8af52a0f7d Provision site.env as the app user, not the SSH user
The data root belongs to the app's uid, so the SSH user usually cannot write
into it, and a site.env they did write would be unreadable by the app — the
admin would stay disabled after provisioning. The script now writes the file
through a container running as the app user, so ownership is right the first
time and no playbook re-run is needed. Also checks the data root exists and
tells you to run the playbook if it does not.
2026-09-22 21:34:04 +05:30
tanshu af083415a7 Make the admin's buttons work in a browser
Every mutating form now carries the CSRF token as a hidden field, and the
upload route accepts it as a multipart field as well as a header. Driving the
real UI in Firefox found what the header-only tests could not: Create Draft,
Save, Publish, Delete and the up/down buttons all posted without a token and
were rejected. Adds npm run check-site, which verifies a running Site from the
outside and exits non-zero so it can gate a cutover.
2026-09-22 21:32:52 +05:30
tanshu bb9ddb437f Ship a repair command and a container
verify reports integrity problems and repair rebuilds derivatives from the
Originals, which is what makes the file layout trustworthy without a database.
The image runs TypeScript on Node 24 with no build step, writes only under
MENU_ROOT, and the provision script writes the untracked site.env using the
image's own hasher so the server needs no Node.
2026-09-22 21:24:47 +05:30
tanshu 9cb4021229 Serve the customer-facing menus
Landing page, Section pages and archived sets rendered per request from the
store, with the approved design: hand-written CSS, self-hosted Archivo, a
copper pour line in the Section rail, an admin-only Thumbnail and a text page
index. Media is keyed by Page id so URLs survive publishing, and a Draft's
media needs a session. Includes the scrypt admin auth and the login throttle
the media rule needs.
2026-09-22 21:18:49 +05:30