Commit Graph
801 Commits
Author SHA1 Message Date
Claude 3fc20238af docs: document the multi-instance service pattern in CLAUDE.md
Establishes multi-instance as the default expectation for any service
that stores its own data and isn't inherently single-tenant, not an
opt-in special case -- matching the direction taken this session
(audiobookshelf, emby, mealie, traccar all just got it; mattermost and
wordpress already had it).

Documents the reusable pattern with a code skeleton (first instance
stays plain-named, adding a second introduces suffixed naming with no
further branching downstream), plus the three sharp edges found while
actually building it into four more services rather than just
theorizing about it:
- dedicated-per-instance databases over shared, and why (Kopia's
  generic backup stops a container to snapshot it, so a shared
  instance backs up and restores as one unit covering every instance
  at once -- this is the same reasoning already applied to
  wordpress.sh, now generalized)
- large port ranges shift by an offset instead of being scanned
  port-by-port, including the find -mindepth 1 gotcha discovered
  while building this into traccar.sh
- sidecar tooling that watches Docker labels host-wide (autoheal)
  needs the label itself scoped per instance, not just container names

Also states plainly: verify this kind of port/count logic by actually
running it, not by reading it -- both real bugs it references were
things code review alone missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:58:25 +00:00
Claude d5d979ac31 Add multi-instance support to audiobookshelf, emby, mealie, traccar
Same pattern already established by services/mattermost.sh and
services/wordpress.sh: first instance keeps the plain name/paths/
ports exactly as before (zero behavior change for anyone with a
single instance already installed), and only choosing to add a second
introduces suffixed naming with its own directory, containers, and
ports.

- audiobookshelf.sh, emby.sh, mealie.sh: straightforward -- suffixed
  dir/container name, auto-scanned free host port(s) via `ss`, Caddy
  subdomain default suffixed to avoid collision. emby.sh's existing
  music-only mode is untouched, just correctly parameterized.
- traccar.sh: the harder one -- has its own dedicated Postgres
  container, an autoheal container, and a 150-port device-protocol
  range that can't be scanned port-by-port. Additional instances shift
  the whole range by 1000 (6000-6150, 7000-7150, ...) based on how
  many traccar/traccar-* directories already exist, which never lands
  on Asterisk's fixed ports the way the first instance's range does,
  so no exclusions are needed there. Also scoped the autoheal label
  per-instance (autoheal-traccar-<suffix>) -- autoheal watches by
  Docker label host-wide, not scoped to a compose project, so two
  instances sharing the generic "autoheal" label would each try to
  manage the other's container too.

Found and fixed two real bugs via testing before committing, not just
code review:
- The device-protocol range offset counted existing instances via
  `find $DOCKER_DIR -maxdepth 1 -name 'traccar*'`, which also matches
  $DOCKER_DIR itself if its own basename happens to start with
  "traccar" (true in my test harness, structurally possible in real
  use too) -- fixed with -mindepth 1.
- Verified port auto-scanning actually detects a simulated in-use
  port and increments past it, using a stateful fake `ss` rather than
  trusting the logic by inspection alone.

Verified end-to-end for all four: first instance unchanged from prior
behavior, second instance gets fully distinct dir/containers/ports,
and (traccar specifically) correct DB container, correctly-scoped
autoheal label, and correct shifted port range in the generated
compose file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:57:23 +00:00
Claude 5f36b14f93 mattermost: add PikaPods migration helper (DB dump + files import)
New opt-in prompt on fresh/new installs (skipped on "update" reruns,
where an existing instance is already in real use and importing over
it would be destructive): "Migrating from an existing Mattermost
instance (e.g. PikaPods)?" -- if yes, generates
migrate-from-pikapods.sh in the instance's own directory, same
generated-helper pattern as Immich's import-photos.sh.

Checked PikaPods' own docs before writing this rather than guessing
at their export mechanics: they expose per-pod SFTP (file access) and
a Database-access toggle that hands you an Adminer link for a full
SQL dump -- their own documented backup/migration flow is stop the
pod, SFTP the files, export the DB via Adminer. The generated script
assumes that shape (plain-text SQL dump + a files directory) and says
so in its header, including that PikaPods' exact SFTP layout wasn't
verified against a live pod so the files-argument path needs the
user's own confirmation.

What the script does: stops the mattermost container (leaves the DB
container running), drops and recreates the database owned by the
same existing role -- so .env's credentials are never touched or
regenerated, avoiding the "restored data, mismatched password" bug
class fixed elsewhere in this repo -- imports the dump via psql,
rsyncs the files directory into ./data, restarts. Requires typing
"YES" to proceed since it's destructive to whatever's currently in
the fresh instance's database.

Correctly parameterized per-instance: pulled from install_mattermost's
own MM_CONTAINER/DB_CONTAINER variables, so it's already correct for
either the first instance or an additional named one.

Verified end-to-end: prompt fires correctly at the right point in the
flow, generated script is syntactically valid, and the container
names/paths it's parameterized with match the actual instance being
installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:45:26 +00:00
Claude 344bdf4a0f docs: settle on 2 WordPress sites, drop actualbudget
Final decision: actualbudget dropped and WordPress site count settled
at 2 (not 4) specifically to restore real headroom after dedicated-
per-site MariaDB made the 4-site case tight. ~1.09GB headroom (~27%)
now, back in the ideal 25-30% range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:37:39 +00:00
Claude a5d57050b3 wordpress: switch to dedicated MariaDB per site (was shared)
Reconsidered after the shared-MariaDB design's real cost became clear:
Kopia's generic backup (services/backup.sh) stops a service's
container to snapshot it, so a shared MariaDB instance would back up
-- and would have to be restored -- as one unit covering every site at
once. Restoring just one site's database to an earlier point meant
restoring the whole shared snapshot to a temporary location first and
manually extracting that site's data back out, not a direct restore.

Each site now gets its own dedicated MariaDB container embedded in its
own docker-compose.yml (same pattern as services/nextcloud.sh) instead
of registering a database on a shared instance:
- Removed _wordpress_ensure_shared_db() and the wordpress-db/
  wordpress_net shared resources entirely.
- Each site's compose file gets a `db` service (container
  <site>-db) on an explicitly-named per-site default network
  (<site>_net), so wp-cli's one-off container reliably joins the
  right network without depending on Docker Compose's implicit
  naming convention.
- DB creation goes through the mariadb image's own MYSQL_DATABASE/
  MYSQL_USER/MYSQL_PASSWORD env vars on first boot (same as
  nextcloud.sh) instead of an imperative `docker exec mysql -e
  "CREATE DATABASE..."` against a shared container.
- Root and site DB passwords are both reused across reruns (read from
  the existing .env), verified via a real update-mode rerun.

Tradeoff, stated in both the script's header comment and the generated
per-site README: more RAM per site (~100-150MB for a full MariaDB
container instead of a slice of one shared instance) in exchange for
independent backup/restore. Data was already fully isolated either way
(separate database + user, always required since WordPress's schema
uses generic table names) -- the shared-vs-dedicated choice was only
ever about the container/process, not the data.

Re-verified end-to-end against the fake docker shim: distinct ports,
distinct dedicated DB containers/networks per site, correct compose/
.env structure, credentials preserved across an update-mode rerun.

docs/vps-sizing-recommendations.md: updated to match -- WordPress
capacity recomputed for dedicated-per-site MariaDB (~580MB headroom at
4 sites, ~976MB at 2, vs. the shared design's ~700MB/~950MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:23:24 +00:00
Claude e96a257d8f docs: record WordPress decision (2-4 sites, ecommerce-capable, Emby dropped)
Emby traded off for WordPress capacity rather than run alongside it —
still fully built and ready in services/emby.sh, just not part of the
current baseline. Updates the final RAM budget table to swap Emby for
the shared MariaDB + WordPress sites, and notes wg-easy/homebox/
audiobookshelf aren't included in that specific table since they
weren't part of the baseline as most recently stated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 21:16:00 +00:00
Claude a26d1831ee Add services/wordpress.sh — multi-site WordPress with shared MariaDB
New service: self-hosted WordPress, sized for running several
independent sites the way a hosting company would, not just one blog.

- Multi-site from the start: every site requires a name (no unnamed
  "first instance" special case like mattermost's — there's no
  backward-compat reason to special-case one here) and gets its own
  directory/container/port, but all sites share ONE MariaDB container
  (chain-installed on first site, reused by every other one) instead of
  a dedicated database container per site — same resource-sharing idea
  as services/coturn.sh, just scoped to WordPress's own sites rather
  than shared across different services. Each site gets its own
  database + user within that shared instance.
- E-commerce is just WooCommerce, a normal WordPress plugin — no
  separate infrastructure. PHP memory_limit/upload_max_filesize/
  post_max_size are pre-tuned (256M/64M/64M) so a product-catalog
  import doesn't hit default-image limits on the first try.
- wp-cli (official wordpress:cli image, run as a one-off container
  sharing the site's html volume) does the initial WordPress core
  install non-interactively — title, admin account — so there's no
  browser setup wizard to remember per site. Falls back to printing
  the exact manual command if the site wasn't ready in time.
- Auto-scans for a free host port per site (multiple sites can't all
  bind 8090), matching the "auto-scanned free ports for extras" idea
  already used by mattermost's multi-instance support.
- DB and admin passwords are reused across reruns (checked against the
  DB-password-regeneration bug class already fixed elsewhere in this
  repo, e.g. PR #265) — verified via a real update-mode rerun that the
  credential doesn't change.
- setup.sh: is_installed() gets a wordpress case — every site is named
  from the first one on, so there's never a plain $DOCKER_DIR/wordpress
  directory the default case could match against.
- README.md: added to the utilities services table + copiable list per
  CLAUDE.md's three-step rule for new services. Also fixed `coturn`
  being in the homelab row's prose but missing from the copiable list
  block below it — a pre-existing gap from when coturn.sh was merged.

Verified end-to-end via non-interactive dry runs against a fake docker
shim (no live daemon in this environment): 3 sites installed in
sequence get 3 distinct databases, 3 distinct auto-scanned ports, the
shared DB is only set up once, and an update-mode rerun preserves the
existing DB password rather than regenerating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 20:57:18 +00:00
Claude 22a6366258 coturn: fix unescaped backticks corrupting generated README + stray error
The "Adding a new service that needs TURN" example in coturn.sh's
write_readme heredoc had one unescaped backtick pair (`sudo ./setup.sh
coturn`) while every other backtick in the same heredoc was correctly
escaped. Since write_readme's heredoc is unquoted (intentionally, so
$DIR-style interpolation works elsewhere in the file), bash treated it
as a command substitution: it actually tried to execute `sudo
./setup.sh coturn` at install time, printed "sudo: ./setup.sh: command
not found" to the terminal on every coturn install, and silently
dropped the intended text from the generated README.

Found while verifying the shared-coturn multi-consumer flow end-to-end
(coturn install -> asterisk + 2 mattermost instances all registering
concurrently) — confirmed working correctly otherwise: three distinct
credential files, no collisions, all three referencing the same host/
port, and reruns correctly reuse the cached credential instead of
regenerating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 20:49:22 +00:00
Claude d2848cecc3 immich: add native S3 storage engine support for thumbnails/uploads
Adds an opt-in prompt to store Immich-managed data (thumbnails, encoded
video, new uploads) in S3-compatible object storage instead of local
disk, using Immich's native IMMICH_STORAGE_ENGINE=s3 — deliberately NOT
a FUSE-mounted bucket. Checked this against real reported issues before
implementing: Immich uses symlinks internally that S3 doesn't support
under FUSE (ENOSYS errors), and its startup does thousands of stat()/
read() calls that FUSE-over-network handles badly enough to crash the
mount under latency spikes as small as 100ms. Native S3 mode talks to
the bucket over the S3 API directly, sidestepping both problems.

Independent of the existing external-library strategy — an external
library (existing photos indexed read-only, e.g. over a VPN mount) is
a separate mount either way and works the same regardless of where
Immich's own managed data lives, since S3 mode only replaces
UPLOAD_LOCATION.

- New prompts: bucket, region, endpoint (for non-AWS S3-compatible
  providers — auto-sets S3_FORCE_PATH_STYLE when given), prefix, access
  key ID, and secret key (read via `read -rs` so it doesn't echo; left
  blank with a warning under UNATTENDED, since there's no sane default).
- Refactored the docker-compose.yml generation from two near-duplicate
  heredocs (with/without external library) into one with composable
  volume-line variables, to avoid quadrupling the duplication once S3
  was added as a second axis.
- Skips creating local upload-location subdirectories entirely in S3
  mode (thumbs/upload/backups/library/profile/encoded-video) — Immich
  manages that structure inside the bucket itself.
- .env now gets chmod 600 (previously ungated) — more pointed now that
  it can hold an S3 secret key, not just the DB password.
- Generated README documents the S3 setup and carries the FUSE-mount
  warning forward so a future reader doesn't try that route instead.

Verified both the non-S3 baseline (unchanged output) and S3 mode
end-to-end via non-interactive dry runs — correct .env, correct
compose volumes, no local upload dirs created, 0600 permissions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 20:06:50 +00:00
Claude 033ffeee48 docs: drop lyrion from the VPS plan, add emby music-only, update swap notes
lyrion was ruled out for two protocol-level reasons Authelia can't work
around (single shared server password, and SlimProto has no auth of its
own for Authelia's HTTP-only forward_auth to gate) — emby covers music
instead, with real per-user library access. Also updates the swapfile
rule of thumb to reflect it now being a default for every install
rather than an Asterisk-droplet-specific behavior, and adds a final
RAM budget table/verdict for the full confirmed service list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 18:14:32 +00:00
Claude ad38b96cfe Make swapfile a default for every install, not just Asterisk droplets
Extracts the swapfile logic out of services/asterisk.sh (previously
DigitalOcean-droplet-gated) into lib/common.sh's ensure_swapfile() —
provider detection was never really the point, the actual condition
that matters is "modest RAM, no swap yet," which applies just as much
to a non-DO VPS running several Docker services at once as it did to a
single-purpose droplet.

- lib/common.sh: new ensure_swapfile(), same fallocate/mkswap/fstab/
  swappiness logic as before, threshold raised from 2048MB to 4096MB
  (a 4GB box running a full service stack is exactly the case that
  motivated this change — the old threshold would have skipped it).
- services/base.sh: calls it unconditionally so every install gets the
  same check regardless of which other services get chosen.
- services/asterisk.sh: swapfile call is no longer gated behind
  IS_DO — calls the shared helper directly. Kept a standalone-mode
  stub (same pattern as this file's other stubbed helpers) so
  `sudo bash asterisk.sh` with no base.sh in the picture still gets
  it. Idempotent either way: a box that already has swap, or already
  got it from base.sh earlier in the same run, no-ops immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 18:13:37 +00:00
Claude 7a66ef2e2f emby: add music-only setup mode with per-user library access guidance
Prompts whether this install is music-only (changes the default folder
to ~/music and the prompt wording — Emby has no compose/env flag for
"music-only", library types are chosen in its own web setup wizard, so
this is guidance plus a sane default, not a functional restriction).
Generated README walks through adding only a Music library and, the
actual reason to pick Emby for this role over Lyrion, per-user library
access under Dashboard → Users → Access — LMS/Lyrion has no equivalent,
just one shared server-wide password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 18:07:09 +00:00
Claude 1b54fdbeb3 docs: correct VPS service recap — drop portainer/syncthing, confirm audiobookshelf
portainer and syncthing were never actually agreed to, and
audiobookshelf-with-remote-home-library access (over NetBird or wg-easy)
was confirmed, not just floated — lyrion remains the only still-open item
from that same idea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 16:57:11 +00:00
Claude 0fde1a2542 docs: add VPS sizing reference and recap of the IONOS box's planned services
Records the sizing methodology worked out for this repo's services (RAM
as the binding constraint, per-service budget ranges, when a swapfile
matters) against two real VPS plans, plus a recap of the full service
list planned for the 4 vCPU/4GB/120GB IONOS box: the core stack, the
utility adds, the NetBird-for-remote-access vs wg-easy-for-local-testing
split, and what was deliberately left out and why.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 16:53:24 +00:00
Claude 4a0ec63a04 wg-easy: hub-routed peer mesh by default, plus SSH-alias sync script
Pins WG_DEFAULT_ADDRESS/WG_ALLOWED_IPS explicitly instead of relying on
wg-easy's own internal default, so every client is created with
0.0.0.0/0 Allowed IPs — client-to-client traffic already routes through
this VPS automatically with no per-pair config, confirmed against
wg-easy's own docs/issue tracker as the documented way to get this
behavior. Also adds an opt-in, additive-only UFW rule to reach SSH over
the VPN subnet (never touches the existing public SSH rule — narrowing
that is left as a manual step so a misconfigured VPN can't lock anyone
out), and a self-contained sync-ssh-aliases.sh companion script that
reads connected peers straight off the live WireGuard interface (`wg
show`, not wg-easy's own undocumented/unstable HTTP API or its
internal storage format) to generate ~/.ssh/config Host aliases.

This is a hub-and-spoke design, not true peer-to-peer mesh: the VPS is
a single point of failure for inter-peer connectivity specifically
(not just VPS access), and unlike NetBird/Tailscale there's no direct
P2P fallback or centralized identity for multi-person key management.
Documented in the generated README as a real tradeoff, not a full
substitute for NetBird once more than one box or one person is
involved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
2026-08-09 12:44:46 +00:00
Outis 6151edeeac Merge pull request #265 from outis1one/claude/droplet-capacity-assessment-s2voix
Claude/droplet capacity assessment s2voix
2026-08-08 17:17:43 -04:00
Claude f731efa2fa Fix DB/admin password regeneration on rerun in 5 services
Same bug class just fixed in mattermost.sh: immich, joplin, koha,
mail-archiver, and nextcloud all generated a fresh random
DB/admin password on every single run with no check for an
existing one. Each backs its database with a persistent volume, so
Postgres/MariaDB keeps the password from its first init while the
freshly overwritten .env (or config-main.env for koha) no longer
matches it — any rerun would have locked the app out of its own
database. koha, mail-archiver, and nextcloud also regenerated an
app-level admin login password the same way.

Found by cross-referencing every service with a DB password against
which ones actually guard reuse on rerun (only traccar.sh did,
already correctly) rather than waiting to be told about each one
individually.

Fix mirrors traccar.sh's existing pattern: read the password back out
of the existing .env/config file if present, only generate fresh when
there's genuinely nothing there yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 17:08:55 +00:00
Claude cf3d4bf6de Extract coturn into a shared service; add Mattermost multi-instance support
Asterisk and Mattermost each used to embed their own dedicated coturn
container (network_mode: host), and their default relay port ranges
overlapped by ~100 UDP ports — running both on one box meant a
coin-flip over which service's active call lost its media relay.

- services/coturn.sh: new shared TURN/STUN relay, one instance for
  every consumer instead of one each. Runs --lt-cred-mech with a
  SQLite user database (not --use-auth-secret — coturn doesn't
  support both auth mechanisms on one instance at once, confirmed via
  coturn's own upstream docs/issues) so each consumer gets its own
  dedicated username/password without stepping on any other's.

- lib/common.sh: ensure_coturn_user() — chain-installs coturn.sh on
  first need (same declare -F guard pattern as the existing
  asterisk -> security-dashboard chaining) and registers/reuses a
  per-consumer credential, mirroring configure_caddy_for_service's
  out-param convention.

- services/asterisk.sh: _asterisk_write_compose gains a
  USE_EMBEDDED_COTURN flag. New installs use the shared service;
  existing installs keep their dedicated coturn exactly as-is on
  every "update" (detected from the existing compose file before
  regenerating it, so a rebuild can never silently drop the container
  its own .env TURN_PASSWORD still points at) and only switch on an
  explicit "fresh" reinstall, with a warning first.

- services/mattermost.sh: same embedded/shared coturn handling, plus
  genuine multi-instance support (separate dir/containers/DB/ports per
  instance, auto-scanned free ports for extras) for real isolation
  between groups, as opposed to Team Edition's built-in Teams feature.
  Calls plugin TURN config switched from the HMAC "TURN Static Auth
  Secret" field to the verified "ICE Servers Configurations" JSON
  field, which accepts the same fixed username/credential shared
  coturn issues. Also fixes a latent bug found while adding proper
  update-mode detection: DB_PASS/MM_SECRET were regenerated on every
  single rerun with no existing-install check at all, silently
  breaking Postgres auth on any reinstall.

- CLAUDE.md: documents the ensure_coturn_user pattern (including the
  auth-mechanism constraint and the embedded-coturn migration-safety
  rule) for any future service that needs TURN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 16:32:08 +00:00
Claude 883f0d2557 Document restricting DMs to teammates in Mattermost's README
Answers a real gap: Teams alone don't limit who can Direct Message
whom — that's a separate System Console setting
(TeamSettings.RestrictDirectMessage), free in Team Edition. Documented
it in the Teams section along with the caveat that it only filters the
DM picker UI, not a hard boundary (existing DMs unaffected, multi-team
users can still DM across all their teams).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 13:57:51 +00:00
Claude 2fc49fa43e Document Teams setup in Mattermost's generated README
Team Edition includes multiple Teams natively (no Enterprise license
needed), but nothing in the generated README said so or explained how
to create one. Added a Teams section covering creation, adding
members, and multi-team membership, plus a one-line pointer in the
install summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 13:48:49 +00:00
Claude b7f69090a6 Sync backup.conf/README to a spare box; make DR bring-up fault-tolerant
- dr_bringup.sh: bound every kopia call and docker compose up with a
  timeout so one stuck service can't stall the rest of the batch, and
  only exit non-zero if literally nothing came up — a partial recovery
  is a partial success, not a failed run.
- backup_kopia.sh: optional DR_SYNC_HOST/DR_SYNC_PATH in backup.conf
  scp's backup.conf + README.md to a spare box over SSH after every
  successful backup, so dr_bringup.sh is ready there with no manual
  copy step.
- backup.sh: prompts for the spare's SSH destination, verifies
  connectivity at install time instead of failing silently at 2am, and
  writes ~/docker/backup/README.md (this service never had one) so the
  synced copy documents every command listed above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 13:40:53 +00:00
Claude ed7270dcf2 Add unattended DR bring-up script for the backup service
restore_kopia.sh is interactive and one-service-at-a-time, which doesn't
scale to standing up a cold spare box quickly during a real outage.
dr_bringup.sh restores every service's latest snapshot (or one named
service) and runs docker compose up -d with no prompts, so a full-stack
recovery is one command instead of N interactive restores.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 13:31:42 +00:00
Outis 048ed4af8e Merge pull request #264 from outis1one/claude/traccar-installation-issues-3scwv7
Move ai-stack and paintplus vendored source under vendor/
2026-08-04 09:10:57 -04:00
Claude b2d064573f Move ai-stack and paintplus vendored source under vendor/
Matches the existing vendor/easy-asterisk convention (used by
services/asterisk.sh) instead of two one-off top-level directories that
cluttered the repo root and didn't look like anything else next to
setup.sh, lib/, services/, extras/. Only the two services' own SRC_DIR
path resolution and header comments needed updating — nothing else in
the repo referenced the old ./ai-stack / ./paintplus paths.

Also documents vendor/ in README.md's Layout section.
2026-08-04 12:55:09 +00:00
Outis 9a1a11a710 Merge pull request #263 from outis1one/claude/traccar-installation-issues-3scwv7
Claude/traccar installation issues 3scwv7
2026-08-04 08:46:04 -04:00
Claude d6092dde4c Move static service docs into services/<name>.md companion files
Applies the new write_readme companion-doc convention to ai-stack,
paintplus, and kyber-launcher: install-time-invariant content (usage
walkthroughs, service tables, troubleshooting) moves out of the .sh
heredocs into sibling services/<name>.md files, leaving only genuinely
install-specific content inline.

- services/paintplus.md: config/cloud/GPU/ai-stack-backend/update/Caddy
  sections, picked up automatically via write_readme's companion-doc
  support.
- services/ai-stack.md: roles, GPU switcher, service URLs, cloud LLM
  provider setup, update, Caddy. ai-stack.sh can't use write_readme
  directly (its POST-INSTALL-NOTES.md filename deliberately avoids
  colliding with the vendored app's own README.md in the same
  directory), so it appends the companion file manually.
- services/kyber-launcher.md: the full SWBF2 (2017) + Kyber walkthrough,
  moved out of the root README's "Gaming scripts" section (which now
  just points here). kyber-launcher.sh now calls write_readme to deploy
  it to ~/.local/share/kyber/README.md, fixing a stale in-script pointer
  to a README section that no longer exists.
2026-08-04 12:34:37 +00:00
Claude 52b4606ab5 Add optional per-service companion doc files to write_readme
Any services/<name>.md next to services/<name>.sh gets appended to the
generated ~/docker/<name>/README.md automatically, with no changes needed
to the calling install_<name>() function. Keeps install-time-invariant
walkthroughs (third-party UI linking steps, multi-account setup) out of
the compose/README heredocs, which should stay focused on values chosen
during install.

Add services/traccar.md as the reference example: documents the
per-user Connections-tab linking needed for ntfy notifications to reach
non-admin accounts, and why TEST CHANNELS can return success without
actually sending anything (Traccar's SMS notificator silently no-ops
when the logged-in user's Phone field is empty).
2026-08-04 12:14:32 +00:00
Outis d56d31d8db Merge pull request #262 from outis1one/claude/traccar-installation-issues-3scwv7
traccar: scripted, idempotent ntfy push-notification setup
2026-08-04 06:57:27 -04:00
Claude 2d7b9f8d56 traccar: scripted, idempotent ntfy push-notification setup
Traccar has no native ntfy integration, but its "SMS" notification
channel is a generic HTTP webhook (sms.http.* config) under the hood —
pointing it at ntfy's JSON publish API instead of a real SMS gateway is
a well-known trick. Adds an opt-in prompt during install (or reruns)
for an ntfy server URL — self-hosted on this box, a different server
entirely, or public ntfy.sh — a topic name, and optional username/
password if that topic needs auth.

Everything lands in .env only: NOTIFICATOR_TYPES, SMS_HTTP_URL,
SMS_HTTP_TEMPLATE, and (if given) SMS_HTTP_USER/SMS_HTTP_PASSWORD.
traccar's env_file: .env already passes these straight through to the
container with no docker-compose.yml changes needed, since Traccar's
env-var config naming convention already matches these key names
exactly. The topic name itself isn't consumed by Traccar (it's set
per-user via the web UI's Phone field), so it's stored as a `#
NTFY_TOPIC=` comment line purely so reruns can recall it and so it's
discoverable on disk.

Reruns default every prompt to whatever was configured last time
(reading it back out of the existing .env), so accepting the defaults
is a no-op — the same non-destructive-by-default pattern already used
for the DB password.

Fixed a heredoc bug caught while testing this: `${_NTFY_ENV_BLOCK}
TRACCAR_ENV` on one line never matches the heredoc terminator, because
bash matches heredoc delimiters against the literal source line before
variable expansion, not after — regardless of what the variable
expands to. Moved the terminator to its own line, matching the
${_CADDY_NET_SECTION} / TRACCAR_COMPOSE pattern already used a few
lines above for the same reason.

Verified: bash -n, and four full install runs (ntfy disabled, enabled
without auth, enabled with auth, and a rerun accepting defaults) with
`docker compose config` confirming SMS_HTTP_TEMPLATE's JSON and all
other values resolve correctly through env_file with no compose-level
escaping needed.
2026-08-04 10:42:09 +00:00
Outis c671fc2163 Merge pull request #261 from outis1one/claude/traccar-installation-issues-3scwv7
Claude/traccar installation issues 3scwv7
2026-08-03 18:45:02 -04:00
Claude e1129b7f27 traccar: never recursively chown the Postgres data directory
Confirmed live: after re-running the installer to pick up the
caddy_net/port fixes, Traccar crash-looped with
"FATAL: could not open file \"global/pg_filenode.map\": Permission
denied" — a Postgres-side error, not a Traccar or Caddy problem.

install_traccar() had two `chown -R $ACTUAL_USER "$TRACCAR_DIR"` calls
(one via ensure_docker_dir_ownership at the top, one explicit near the
end) inherited from the original H2-only script, where that was safe —
everything under the directory (logs/, data/, config/) was meant to be
host-user-owned. Once db/ started holding Postgres's own data files
(owned internally by whatever uid the postgres container runs as, not
$ACTUAL_USER), both of those recursive chowns reassign db/'s contents
to $ACTUAL_USER on every rerun, and Postgres can no longer read its own
files afterward.

Replaced both with non-recursive/scoped chowns that never touch db/:
the top-level directory itself, docker-compose.yml, and .env directly,
plus a separate `chown -R` limited to logs/ and data/ (which are
Traccar's own app-writable directories and always safe to reassign).
2026-08-03 21:34:15 +00:00
Claude 2ced57db55 traccar: also exclude Asterisk's SIP ports (5060 tcp+udp, 5061 tcp)
The previous fix only excluded 5038 (AMI). Confirmed live: after
attaching caddy_net, `docker compose up -d` still failed —
"failed to bind host port 0.0.0.0:5060/tcp: address already in use" —
because Asterisk (network_mode: host) also owns 5060 (SIP, tcp+udp)
and 5061 (SIP TLS, tcp), both inside Traccar's 5000-5150 range.

Asterisk gets priority on all three ports; Traccar's range just skips
them. Audited every other network_mode: host service in the repo
(caddy, homeassistant, kyber-server, lyrion, mattermost, watchyourlan,
wolf-pair, wolf) — none of them land in 5000-5150, so Asterisk is the
only conflict to account for.
2026-08-03 21:01:22 +00:00
Claude 78692c5999 traccar: exclude Asterisk's AMI port (5038/tcp) from the published range
Confirmed live: on a box also running Asterisk from this repo,
`docker network connect caddy_net traccar` failed with "failed to bind
host port 0.0.0.0:5038/tcp: address already in use". Asterisk runs with
network_mode: host (services/asterisk.sh), so its AMI (port 5038,
hardcoded in services/sms-inbound.sh) binds directly on the host's
network stack — no Docker NAT involved. Traccar's docker-compose.yml
published the entire 5000-5150 range for device protocols, which
needs Docker to also bind host port 5038 for its own port-forwarding,
directly colliding with Asterisk's AMI on any box running both
services from this repo.

Split the TCP range into 5000-5037 and 5039-5150 to skip that one
port; left UDP as a single 5000-5150 range since AMI is TCP-only.
2026-08-03 20:56:41 +00:00
Outis 398950cf65 Merge pull request #260 from outis1one/claude/traccar-installation-issues-3scwv7
Claude/traccar installation issues 3scwv7
2026-08-03 16:32:21 -04:00
Outis 41b00bef1d Merge pull request #259 from outis1one/claude/drum-game-service-sync-twnt5n
Sync drum-rhythm-game service with upstream's Docker deployment
2026-08-03 16:31:48 -04:00
Claude 737bc873d1 traccar: fix stale admin@admin.com/admin default-login messaging
Current Traccar images ship with no built-in account at all — the login
screen's Register flow creates the first user, and that user is
automatically made admin. The admin@admin.com/admin default our messages
still quoted belongs to older Traccar versions and no longer exists,
so anyone following our own output would try that login and fail.

Updated the dry-run summary, README, and final on-screen message to
describe the real flow, and to flag that self-registration stays open
to anyone who reaches the server until it's turned off (Settings →
Server → Permissions), since that's a real exposure window on a
freshly-installed instance with no way to lock it down at config time.
2026-08-03 18:56:02 +00:00
Claude 1fc0a6edfe Apply the local/remote Caddy mode resolution to every service, not just traccar
traccar.sh's caddy_net wiring was fixed to mirror configure_caddy_for_service's
own mode resolution (CADDY_MODE from site config, then a local ~/docker/caddy,
then the legacy CADDY_REMOTE_HOST var) instead of only checking for the local
directory. That same bare directory check was copy-pasted into the caddy_net
wiring of every other Docker service in the repo, so a site with Caddy on a
different box would silently fail to join any of their containers to caddy_net
during setup (or, for homeassistant/koha, only get half the wiring right).

Applied the same fix mechanically across all 37 services using the standard
_CADDY_NET_BLOCK/_CADDY_NET_SECTION pattern (verified identical text via
scripted diff before touching any of them), plus by hand for:
- homeassistant.sh and koha.sh, which use their own differently-shaped
  variables (HA_CADDY_NET_LINES / _CADDY_NET_ENTRY) for the same decision
- paintplus.sh and ai-stack.sh, which do a live `docker network connect`
  instead of a compose network block
- watchyourlan.sh, whose Caddy note was worded for local-only setups

sms-inbound.sh got more than a mode swap: its Caddy wiring was hand-rolled
(not routed through configure_caddy_for_service) and had no remote-Caddy
path at all — a remote Caddy box would get a misleading "Caddy isn't
installed here" message instead of a snippet. Added
_sms_write_caddy_snippet(), mirroring the snippet-file pattern
configure_caddy_for_service uses everywhere else, and pointed the firewall
gate at the same three-way mode instead of a two-way dir check.

Verified: bash -n across all of services/*.sh, a scripted check that every
touched file has exactly one _CADDY_MODE resolution and no leftover bare
`[ -d "$DOCKER_DIR/caddy" ]` feeding a caddy_net decision, and spot-checked
docker compose config renders (traccar, mattermost) confirming the ${VAR}
interpolation and multi-service usage sites still resolve correctly.
2026-08-03 18:54:02 +00:00
Claude 17201b647f Sync drum-rhythm-game service with upstream's Docker deployment
The game repo grew from a single genre/song count to 18 genres (119
synth-orchestra songs + 120 drum patterns), gamepad remap, and multiplayer,
and added its own Dockerfile/nginx.conf/.dockerignore specifically so only
index.html gets served. This service had fallen behind on both fronts: it
bind-mounted the whole cloned repo into nginx:alpine, publicly serving
README.md, CLAUDE.md, DEPLOYMENT.md, the Dockerfile itself, and old
versions_to_compare/*.html snapshots alongside the game. Build from the
repo's own Dockerfile instead (matching its .dockerignore) so only the game
is served, with gzip and /healthz along for free, and refresh the written
README's stale feature description.
2026-08-03 18:50:04 +00:00
Claude 6554256e8e traccar: drive database config from .env instead of a static XML file
The previous fix still baked database.user/database.password directly
into config/traccar.xml, duplicating the secret that .env already held
and leaving a second, unmanaged copy of it on disk.

Traccar supports reading its config from environment variables
(CONFIG_USE_ENVIRONMENT_VARIABLES=true, confirmed against the official
traccar/traccar docker/compose/traccar-mysql.yaml reference). Use that:
DATABASE_DRIVER/URL/USER/PASSWORD are now set in the compose file via
${POSTGRES_*} interpolation from .env, so .env is the only place the
credentials live — drop config/traccar.xml and its volume mount
entirely, matching the official reference example.

Also switched to the official reference healthcheck (wget against
/api/health, 1h start_period) — a real endpoint on real hardware rather
than a guessed /dev/tcp probe against an unverified image's toolset —
and added the interval/start-period env vars to the autoheal container
to match, while keeping the container scoped to just Traccar via the
autoheal=true label instead of the reference's host-wide "all".

Verified with `docker compose config` (both with and without a local
Caddy directory present) that the ${POSTGRES_DB}/${POSTGRES_USER}/
${POSTGRES_PASSWORD} references resolve correctly from .env with no
warnings.
2026-08-03 18:46:23 +00:00
Claude 5a329cdd48 traccar: resolve Caddy local/remote mode like configure_caddy_for_service does
The caddy_net wiring for the new db/traccar containers was gated only on
whether ~/docker/caddy exists locally, so it didn't account for a site
where Caddy runs on a different box (CADDY_MODE=remote or the legacy
CADDY_REMOTE_HOST var, set with no local Caddy directory). Resolve the
mode the same way configure_caddy_for_service does — explicit CADDY_MODE
first, then the local directory, then CADDY_REMOTE_HOST — so caddy_net is
only joined when Caddy is actually local. A remote Caddy reaches Traccar
via this host's published 8082 port regardless, so no other change is
needed for that path.
2026-08-03 18:43:02 +00:00
Claude 09eff1e397 Fix Traccar install: add PostgreSQL database and autoheal
Traccar's docker image no longer bundles the H2 driver, so the
generated traccar.xml (org.h2.Driver / jdbc:h2:...) failed at startup
with no working database. Add a postgres:15-alpine db container with
a healthcheck, point traccar.xml/POSTGRES_* at it via .env, and gate
traccar's startup on db being healthy.

Also add a willfarrell/autoheal container scoped to just the traccar
container (via the autoheal=true label) that restarts it if its own
TCP healthcheck on 8082 fails.

Reruns reuse the existing DB_PASS from .env instead of generating a
new one, since the postgres volume keeps the original password from
its first init.
2026-08-03 18:02:08 +00:00
Outis a110bbded9 Merge pull request #258 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Claude/asterisk caller id number y1b3ls
2026-07-28 23:38:26 -04:00
Claude 9390382bf4 Add Sipnetic QR-scan provisioning and a kiosk client installer download
Two concrete asks: "a QR code generator for sipnetic... displayed on the
security website" and a real download for the baresip kiosk client
instead of just pointing at docs.

Sipnetic QR: ea_device_sipnetic_string() builds Sipnetic's own documented
account-string format (n=/u=/d=/p=/dt=, semicolon-separated, from
https://www.sipnetic.com/qr-codes) from the exact same data the existing
device-details panel already reads back out of pjsip.conf -- unlike
ea_device_provisioning()'s deliberately generic plain-text file (written
because no vendor XML format could be verified), this one has real
documentation to build against. Rendered client-side with
davidshimjs/qrcodejs (MIT, wraps Kazuhiko Arase's original QRCode for
JavaScript) embedded verbatim with its license header intact -- no CDN
call, no new Python dependency, same self-contained approach as the rest
of this page. New "Sipnetic QR code" button on each extension's detail
panel; verified the embedded library actually renders (not just parses)
via a real jsdom run producing real QR cell output.

Kiosk installer download: _secdash_copy_kiosk_installer copies
vendor/easy-asterisk/easy-asterisk-v0.10.0.sh alongside the deployed app
at install time (both update and fresh-install paths) and a new
/download/kiosk-client-installer.sh route serves it -- linked directly
from the Ring Groups card's help text instead of only being reachable by
manually finding the file in the repo. Copied at install time rather
than read live, since a standalone run of this one file (no full repo
clone -- explicitly supported here) has no vendor/ directory to read
from; missing source degrades to a clean 404 on that one link, not an
install failure.

Verified: bash -n, py_compile on the extracted embedded app.py, node
--check on the extracted embedded JS (including the newly-embedded QR
library), and the account-string output checked directly against
Sipnetic's own documented example format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-29 03:30:13 +00:00
Claude 646b7b6fde Let an existing Ring Group's type/timeout be edited, not just set at creation
Rename, member add/remove, and DID assignment could all already be
changed after a room existed -- type and timeout could only be set once,
at creation, with no way to flip an existing Ring group to Page (or back)
without deleting and recreating it, losing its members/DID assignment in
the process. Reported directly: "I can't edit the ring group to change
it to a page group."

Turns the Timeout/Type columns into inline-editable controls (matching
the same select/input the creation form already uses) with a Save button
per row, backed by a new ea_update_room_settings() that rewrites just
those two fields in rooms.conf, leaving name/members/DID untouched --
same read-modify-write pattern ea_rename_room() already uses.

Verified: bash -n, py_compile on the extracted embedded app.py, node
--check on the extracted embedded JS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-29 03:03:15 +00:00
Outis 79775d41f8 Merge pull request #257 from outis1one/claude/asterisk-caller-id-number-y1b3ls
Document Ring vs Page and auto-answer kiosks, in the dashboard itself
2026-07-28 20:54:44 -04:00
Claude ca750f5432 Document Ring vs Page and auto-answer kiosks, in the dashboard itself
The user asked for the security webpage to actually explain how to set
these up, not just this conversation. Adds a "How this works" disclosure
to the Ring Groups card (matching the existing help-block pattern already
used on Extensions and Personal numbers) covering:

- Ring vs Page's actual difference (first-answer-wins hunt group vs
  Asterisk signaling auto-answer via SIP headers).
- Mixing an auto-answering device with normally-ringing phones needs no
  new group type or per-member setting -- auto-answer lives in the
  device's own SIP client config, so a plain Ring group already dials
  everyone at once and an auto-answer-configured device just picks up
  faster than a human can.

New docs/kiosk-paging-setup.md walks through the concrete path for a
dedicated always-on auto-answer device: Easy Asterisk's own baresip-based
"kiosk" client, installed on a separate small Linux machine (not the
Asterisk server's Docker container -- the vendor script explicitly skips
baresip install when it detects Docker locally). Also notes plainly that
a browser/WebRTC auto-answer option doesn't exist on this server today
(no WSS transport configured) and would be new infrastructure work, not
a quick addition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDyKC6Kdg7tofmYSmRtgww
2026-07-28 23:57:42 +00:00
Outis b166170481 Merge pull request #256 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse
Read domain/TURN from Easy Asterisk's own config, and offer a setting…
2026-07-28 16:23:01 -04:00
Claude eaca45c83c Read domain/TURN from Easy Asterisk's own config, and offer a settings download
The panel kept showing "no domain set" and "TURN not configured" on a box
that has both. The cause was reading the wrong file: I had it reading the
host-side .env, a 600 file needing an ACL grant, a ProtectSystem exception
and a systemd Environment= line to even locate — three things that each had
to be right, and weren't.

The vendored web admin gets this right because it reads
/etc/easy-asterisk/config, written by the container's own entrypoint. That is
mounted on the host inside ASTERISK_EA_CONFIG_DIR, a directory this dashboard
is already granted read access to for rooms.conf. So it now reads that first,
falls back to .env, and finally to `docker exec cat` of the same file — each
source only filling gaps the previous left. No new permissions, and the value
shown is what Asterisk is actually running with rather than what the
installer asked for.

Also adds the requested provisioning file: a "Download settings" link per
extension serving a plain text file with server, username, password, display
name, transport, port, SRTP, a ready-made SIP URI, and TURN server/user/
password. Deliberately not a vendor-specific format — Sipnetic, Linphone,
Zoiper and Groundwire each want a different one and none could be verified
from here, and a confidently-wrong .xml is worse than a file you can read. It
warns in-file when the extension is UDP-only, when the cert is self-signed,
and that it contains a password.

The panel gains the SIP URI and says when the address shown is the host IP
rather than a configured domain.

Fixes a JS syntax error introduced with that note: an apostrophe escaped for
Python's benefit left a bare quote inside a single-quoted JS string, which
broke the whole page script — the table rendered empty. Now checked properly
by extracting the script and running `node --check` over it, which catches
this class of fault directly instead of inferring it from missing elements.

Verified with only Easy Asterisk's config present and .env absent entirely —
the state that failed before: domain, TURN server, user and password all
resolve, the download serves with the right filename, and the page has no
errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-28 20:19:46 +00:00
Outis 632cdb1c1c Merge pull request #255 from outis1one/claude/asterisk-digital-ocean-consolidate-9dsrse
Claude/asterisk digital ocean consolidate 9dsrse
2026-07-28 16:13:10 -04:00
Claude 1b3e7a6110 Fix extensions created from the dashboard, and show details by their row
Three separate faults, reported together as "the old web admin made
extensions correctly and this doesn't".

1. Every write through ea_docker_write left pjsip.conf owned by root. `tee`
   runs as root inside the container while Asterisk runs as `asterisk` and
   expects to own its own config — the vendored admin's add_device() chowns
   it back immediately after writing, and services/asterisk.sh's device
   migration does too. This was the one writer in the project that didn't,
   and is the most likely reason an extension created here behaves
   differently from one created in the vendored admin. Now chowned after
   every write, with a scoped sudoers entry for it, and a warning logged if
   the chown itself fails rather than passing silently.

2. The dashboard's update path rewrote the systemd unit and then restarted
   the service without daemon-reload, so systemd kept running the cached
   unit. Any Environment= line added since the last FRESH install was written
   to disk and ignored — which is exactly how a box with DOMAIN_NAME and TURN
   both set in .env still reported "no domain set" and "TURN not configured".

3. The connection panel rendered at the top of the card, so on any table long
   enough to scroll, the answer appeared off-screen above the row that was
   clicked. It is now a table row injected directly beneath its own
   extension, toggled by the same button, and it survives the transport and
   password actions by reopening after the reload they trigger.

Also: LAN devices now get ice_support=yes when a TURN server is configured,
matching the vendored admin (a device given TURN credentials but no ICE can't
use the relay); the panel reports the device type, so a Mobile extension can
be confirmed as such; and an unreadable .env now says which of "path not
set", "file missing", "permission denied" or "TURN_SERVER empty" applies
instead of the flat "not configured" that covered all four.

Verified: the three .env failure states each produce their own message; the
detail row lands directly after its anchor, only one is ever open, it clears
on re-render and reopens rather than sticking closed; no duplicate or missing
element IDs and no JS errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
2026-07-28 20:08:49 +00:00