Release 0.213
0.213.1: The rest of the “all good” over a dead brain (branches feat/healthcheck, feat/sanity-services, feat/test-timeouts)
Four layers now have to agree before an install calls itself healthy. The installer work closed the reporting side; this closes the two places that were still capable of staying quiet.
The web container’s healthcheck notices it has no network. When a published
port can’t bind, Docker abandons that container’s whole network setup and the
process keeps running — attached to nothing, unable to resolve postgres,
unreachable by Caddy. Every HTTP-only check passes there, because 127.0.0.1
inside the container works perfectly. It now asks os.networkInterfaces()
for a non-internal interface before asking whether the server answers.
Deliberately not a probe of postgres: Caddy gates on this check, so folding a
peer’s health into it would let a routine database restart take the front door
down. “Do I have an interface” answers the real question and depends on nobody.
The health check names services that were never created. Everything else
can only judge containers that exist, so a service that failed to be created
dropped out of the report entirely. Removing tika from a live stack used to
read “All good — 23 healthy”; it now names it and exits non-zero. Safe because
the expected list honours COMPOSE_PROFILES from the install’s own .env — an
opted-out embedder isn’t reported missing, sandboxd is expected once
sandboxes are on.
Two bugs surfaced while building it. Repeating a label filter with the same
key ANDs the terms, so a query for two compose projects at once matches
nothing — the straggler cleanup in uninstall.sh was built on the opposite
assumption and was a silent no-op. And sanity.sh derived the stack directory
from its own location while accepting an env-file override, so --stack-dir
could read one install’s .env against another’s compose file; it honours
MANTLE_STACK_DIR now, and the installer passes it.
Test timeouts, separately. Two tests failed the pre-push gate on a green
tree, passed alone in 1.7s, and never failed on macOS. Both load modules
inside the test body, so module resolution and the esbuild transform are
billed against the 5s testTimeout — and vitest runs about one worker per
core, so on a 24-core box under real load a require('mathjs') costing 411ms
idle sails past five seconds. testTimeout is now 15s, and mathjs moved to a
module-scope import so 18MB leaves the per-test budget entirely.
0.213.1: An uninstaller, and a project-name bug it exposed (branch feat/uninstall)
scripts/uninstall.sh — there was no supported way to remove Mantle, so
everyone improvised, and the improvised version is the one that eats a
database. It splits the operation in two, because only one half is reversible.
The default removes containers, networks and named volumes and keeps your
data: postgres, the object store, files and backups are bind-mounted into
MANTLE_DATA_DIR, and the only named volumes are a tailscale socket and
Caddy’s cert cache — so a re-install brings the same brain back with the same
keys. --purge additionally deletes the data directory and .env, which is
the brain plus MANTLE_MASTER_KEY; without that key a vault cannot be
decrypted even from a later backup, so it asks you to type PURGE rather than
press y. --dry-run prints the blast radius and changes nothing, --images
reclaims the ~4 GB of pulled images, and the whole thing refuses to run against
the mantle-dev development stack or without a terminal to confirm on.
Root-owned data (containers create it as root) is removed via sudo where
available and otherwise through a throwaway container — no password needed.
Writing it surfaced a bug in the installer merged earlier this cycle: port
ownership was keyed on a project name derived from the stack directory, but
both compose files set name: explicitly, and that wins. On any box not
installed into a directory literally called mantle, ownership detection
would have failed and every re-run would have relocated a working front door to
:8080. Now read from the compose file, with compose’s own precedence.
Also fixed in both scripts: probing for a controlling terminal leaked
/dev/tty: No such device or address onto stderr in a piped or detached run —
redirections apply left to right, so the failure printed before 2>/dev/null
took effect.
0.213.1: Onboarding: orientation before the first message (branch feat/onboarding-tutorial)
The last screen said “you’re all set” and handed you to the assistant. It now says what to do with it, in four lines total.
The lead carries the thing that makes the rest cohere and that nobody guesses: Mantle takes in whatever you give it and indexes it automatically — there is nothing to tell it to learn or remember. People arriving from chat assistants go looking for a “remember this” step, and since no builtin exposes that verb, the search ends in doubt about whether anything was stored at all.
Then three items, and deliberately no more. Files are indexed on arrival, so a document can be asked about the moment it lands (large ones take a minute — extraction is a concurrency-capped queue, not an instant embed, and the copy says so rather than promising magic). Email is gated on the contacts list: no contacts means nothing inbound is ingested, which is indistinguishable from a broken mail setup unless you’re told it’s deliberate — with the real carve-out, that your own mail always comes in. And everything else happens by asking.
0.213.1: Installer: guided setup, honest health checks (branch feat/install-probe)
An install can no longer report itself healthy when it isn’t. A host port
already holding :3000 made Docker abandon the web container’s entire network
setup; it stayed running and healthy — its healthcheck only probes inside
itself — with no network, no postgres, and unreachable by Caddy. The sanity
check then confirmed the illusion: it probed http://localhost:3000 first and
accepted any 2xx–4xx, so the squatter on that very port answered with Mantle’s
own 307 → /login. “All good — 23 services healthy” over a dead brain.
Now: the web container’s debug port is configurable (MANTLE_WEB_DEBUG_PORT)
and the installer picks a free one; the health check probes the front door
and proves Mantle answered via /api/auth/bootstrap-state instead of trusting a
status code; a container running with no network or an unbound published port
fails loudly; and the check’s verdict is the installer’s verdict — a failure
ends in “Installation incomplete” and a non-zero exit.
The front door’s host ports moved the same way (MANTLE_HTTP_PORT /
MANTLE_HTTPS_PORT), because a busy :80 killed a container that matters far
more than the debug tunnel. Without a certificate the installer just moves to
8080 and prints the address with its port; with a domain it refuses to move and
says why — HTTP-01 is answered on 80 and TLS-ALPN-01 on 443, so any other port
means no certificate, ever. --behind-proxy covers the box that already runs
nginx: Caddy on loopback:8080, the existing proxy keeps :443.
That also fixed a live bug in MANTLE_SERVER_ORIGIN, which the client app
serves to the browser as apiBase: it was hardcoded to http://localhost for
every non-domain install, so a --lan box sent every remote browser’s API call
to its OWN machine. It now tracks the address the installer actually tells you
to open, port included.
The installer also asks the question that shapes the install — a domain with
HTTPS, this machine only, or this machine’s network — instead of one yes/no
about a domain. --localhost binds the front door to loopback
(MANTLE_BIND_ADDR), which is the only thing that genuinely keeps a brain off
the network given a published Docker port bypasses the host firewall. A domain
is verified before TLS is enabled — every A and AAAA record against the
box’s public and local addresses, via getent then dig/host — and a mismatch
offers a re-check, plain HTTP, another domain, or a clean stop rather than
letting Caddy burn that hostname’s Let’s Encrypt limit; unattended, it falls
back to HTTP instead of proceeding into a doomed request. Prompts read from
/dev/tty, so curl … | bash can ask real questions instead of answering them
all from an empty stdin. Disk, memory and ports 80/443 are checked before the
~2 GB pull.