The 31 containers/<app>/<app>.sh files each defined install<App>() with
the SAME 10-step sequence — ~4,000 lines of duplicated boilerplate.
Replaces all that with one generic driver + hook surface.
scripts/app/install/app_install.sh:
installApp <slug> [config_variables]
— Dispatches on $<slug> (c/u/s/r/i) the same way the per-app .sh
files did. Same convention; dockerInstallApp's existing
`declare $app=i` callsite needs no change.
— Runs the standard sequence: dockerConfigSetupToContainer →
dockerComposeSetupFile → optional .env copy → fixPermissions →
dockerComposeUpdateAndStartApp → standard post-install steps
(appUpdateSpecifics, setupHeadscale, databaseInstallApp,
webuiContainerSetup, monitoring registration) → final message.
— Hooks (all declare-f-gated, silent no-op when absent):
<slug>_install_pre / _post_setup / _post_compose / _post_start
<slug>_install_message_data (echoes extra args for menu)
<slug>_install_post
<slug>_uninstall_pre / _post
<slug>_stop_post
<slug>_restart_post
Hooks live in containers/<app>/tools/<app>_tools.sh (auto-sourced
per the modular-per-app-tools convention).
function_install_app.sh:
When no install<App>() function exists, fall through to
`installApp <app_name>` instead of erroring. So an app with no .sh
at all becomes a zero-byte addition — drop in <app>.config +
docker-compose.yml + <app>.svg, done.
containers/linkding/linkding.sh:
Deleted (canary). Linkding's body was 100% standard sequence;
fallback handles it identically. Smoke-tested with stubbed helpers
— dispatcher fires, generic runs full flow, monitoring integration
+ final-message hook plumbing all intact.
Wave B (next): delete the .sh for every other 'pure-boilerplate' app
(~15 candidates per the survey). Wave C: extract custom logic from
the 7 fat apps into hooks before deleting their .sh.
Signed-off-by: librelad <librelad@digitalangels.vip>
Linkding has shipped without any Tools-tab actions since v0.1.0 — the only
artifact was scripts/menu/tools/manage_linkding.sh, a dead legacy CLI menu
referencing an `appLinkdingSetupUser` function that was never defined.
Build the real thing, mirroring bookstack's pattern (manifest + thin tool
wrappers + auth_adapter that drives the app's native admin shell):
containers/linkding/tools/linkding.tools.json — manifest, 5 tools
containers/linkding/tools/linkding_<id>.sh — one wrapper per tool
containers/linkding/scripts/linkding_auth.sh — Django shell driver
Tools (all category=users, so the WebUI's custom user-list panel and its
row-level 🔑 / 👑 / 🗑 buttons light up):
reset_password — set_password on an existing user (random if blank)
create_account — create_user / create_superuser
list_users — emits EZ_USER\t<username>\t<username>\t<role> rows
(linkding is username-primary, so username goes into
both display slots — keeps the panel click-through
identifier consistent with the other tools' fields)
delete_user — delete by username (destructive, confirm gated)
set_admin — toggle is_superuser + is_staff
Implementation runs entirely inside the linkding-service container via
`runFileOp docker exec ... python manage.py shell -c "<code>"`, reading
inputs through `-e` env vars so quoting stays safe. Django's default
get_user_model() User is used directly — passwords hash exactly the way
the web UI does, admin flags map to the same fields the UI reads.
Also drop the dead legacy stub (scripts/menu/tools/manage_linkding.sh)
and regenerate files_menu.sh so the source-scan no longer pulls it in.
Nothing referenced linkdingToolsMenu — verified by tree-wide grep.
Verified live on dev-ai (Debian 12, linkding installed, Django 5 + sqlite):
$ libreportal app tool linkding create_account 'username=alice|password=…|admin=true'
✓ Linkding user created — Username: alice — Password: …
$ libreportal app tool linkding list_users ''
EZ_USER alice alice admin
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Now that uid auto-discover is in (d424473), adding more apps is just naming the
container + path — no uid guessing. Four more apps get complete backups of their
private data dirs (previously: incomplete on the file side because libreportal
can't read sub-UID-owned content from the host).
- linkding-service -> /etc/linkding/data (overlaps with sqlite db, same pattern as gitea — dump replays over the captured tree on restore, harmless)
- vaultwarden-service -> /data (same overlap pattern)
- headscale-service -> /var/lib/headscale (same overlap pattern)
- mastodon-service -> /mastodon/public/system (uploads; postgres handled separately by backup.db)
Coverage now: nextcloud, bookstack, gitea, owncloud, linkding, vaultwarden,
headscale, mastodon. Skipped jellyfin — it has multiple internal sqlite DBs and
no backup.db declared; adding just backup.files without backup.db / backup.live
wouldn't activate live capture, and adding backup.live blind could yield torn
sqlites. That one wants proper DB declaration first.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Adds CFG_<APP>_BACKUP_STRATEGY (default auto) so an app's backup strategy can
be overridden from its Advanced config tab, taking precedence over the global
default. Added to the 10 live-capable apps, so the dropdown's 'live' option only
appears where it actually works.
- backupResolveStrategy now checks the per-app override before the global value.
- backupAppLiveCapable / backupAppStrategyOptions expose capability + the valid
option set; predicate helpers hardened with explicit returns so they behave
identically with or without shell errexit.
- BACKUP_STRATEGY field mapping (select, advanced) renders the dropdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add libreportal.backup.db labels for the SQLite apps with confirmed db paths:
vaultwarden, linkding, trilium, headscale, authelia. These are dumped live via
sqlite3 .backup and rehydrated before start on restore.
gitea and focalboard are intentionally left out until their sqlite paths are
confirmed on a live install — a wrong path would just fall back to stop, but
there's no point shipping a descriptor that always falls back.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The static per-app CFG_<APP>_HOST_NAME is gone. host_setup (the app's
canonical FQDN, feeding the legacy single DOMAINSUBNAME_DATA used by app env
vars, the app URL and trusted-domains) is now derived from the app's primary
Traefik port's subdomain: first recommended port, else first Traefik port;
@/root -> apex, set -> sub.domain, empty -> app-name. Removes HOST_NAME from
all app configs, the config-form field mapping (Hostname), the dead
headscale stub, and wireguard.sh (now uses host_setup). Completes the move to
dynamic per-port subdomain routing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the static one-host-per-app model with per-port routers: each
Traefik-managed port carries a subdomain (12-col PORT format) and gets a
DOMAINSUBNAME_TAG_<n> host, so one container can serve unlimited hosts.
tagsProcessorPortSubdomains stamps per-port hosts (subdomain @/empty = apex,
multi-level allowed); tagsProcessorPortRouterBlocks comments out
# TRAEFIK_PORT_<n>_BEGIN/END blocks for non-Traefik ports so unfilled
placeholders never ship (mirrors GLUETUN_OFF). Convert all 27 router apps
(subdomains seeded from HOST_NAME; headscale admin. prefix -> subdomain).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.
Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>