167 Commits
Author SHA1 Message Date
Outis 8f83c96ca7 Merge pull request #451 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: fix ES-DE GitLab AppImage download resolving no URL
2026-09-09 23:17:10 -04:00
Claude 750c55a4d4 wolf: fix ES-DE GitLab AppImage download resolving no URL
Two compounding bugs in _wolf_download_emulator_appimage_gitlab() made
the ES-DE AppImage download always fail with "Could not resolve
download URL", confirmed live against the real GitLab API:

1. The asset filter checked url.endswith(".AppImage"), but GitLab's own
   release-asset URL is an opaque .../package_files/<id>/download link
   with no filename in it at all — only the asset's own "name" field
   (e.g. "ES-DE_x64.AppImage") carries the real filename. Filtering on
   the URL suffix matched nothing, even though the latest ES-DE release
   genuinely ships x64/aarch64/SteamDeck AppImage assets.

2. Even with the URL resolved, the download target was built as
   $dir/$(basename "$_url"), which for that same opaque URL evaluates
   to just "download" instead of the real filename — breaking every
   downstream step that looks for a *.AppImage file (the ES-DE.AppImage
   symlink creation, and the "already downloaded" idempotency check on
   a later rerun).

Fixed by filtering on the asset's own "name" field and threading that
name through (tab-separated from the URL) to use as the actual saved
filename. Verified end-to-end against the live GitLab API: resolves to
ES-DE_x64.AppImage and downloads a real, correctly-arched ELF binary.

(Also had to drop an f-string in the same python snippet — pre-3.12
Python disallows a backslash inside an f-string's {} expression, and
separately this whole snippet is wrapped in a bash single-quoted
string, so it can't contain single quotes at all either. Plain string
concatenation avoids both constraints.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 03:16:27 +00:00
Outis a03f976eb1 Merge pull request #450 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: fix auto steam-setup-frontends invocation ($0 resolution)
2026-09-09 23:16:17 -04:00
Outis 2820471654 Merge pull request #448 from outis1one/claude/pensive-hopper-4c9e7i
Add anki-progress service + anki-deck-*.py generation tools
2026-09-09 23:15:40 -04:00
Claude 92e9ee19a7 wolf: fix auto steam-setup-frontends invocation ($0 resolution)
install_wolf()'s new auto-wire-up called it as 'bash manage.sh
steam-setup-frontends', which sets $0 inside manage.sh to the bare
string 'manage.sh' (no path). steam-setup-frontends re-invokes itself
per emulator via "$0" steam-add-nonsteam-game ..., and a bare
'manage.sh' with no '/' triggers a $PATH lookup instead of running the
local file — confirmed live: every emulator already downloaded (Azahar,
PCSX2, Cemu, Dolphin) failed with 'manage.sh: command not found' and
the run ended with a false "No emulator AppImages found" even though
they were sitting right there in emulators/.

Invoking it as './manage.sh' instead keeps $0 as './manage.sh', which
resolves correctly for the nested re-invocation, matching how every
other caller in this file already runs it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 03:06:00 +00:00
Outis 15f594e4eb Merge pull request #449 from outis1one/claude/steam-non-steam-apps-visibility-p1uqc2
wolf: auto-add downloaded emulators to Steam's library on rerun
2026-09-09 23:01:18 -04:00
Claude cf879f0090 wolf: auto-add downloaded emulators to Steam's library on rerun
Downloading an emulator AppImage (Cemu, Azahar, PCSX2, Dolphin, ES-DE,
RetroArch) only ever dropped the file into emulators/ — getting it into
Steam's own shortcuts.vdf as a non-Steam game still needed a separate,
manually-typed manage.sh command per emulator, and steam-setup-frontends
only ever covered ES-DE/RetroArch, leaving Cemu out entirely.

- steam-setup-frontends now scans emulators/ and adds every AppImage it
  finds (deduping a fixed-name symlink like ES-DE.AppImage against the
  real versioned file it points at), instead of only handling ES-DE and
  RetroArch by name.
- install_wolf() now calls it automatically at the end of a run whenever
  Steam already has a signed-in profile, so the flow is just: run
  setup.sh wolf, sign into Steam via Moonlight, run it again — no
  separate manage.sh command to remember.
- Docs (manage.sh help text, printed install summary, generated
  README.md) updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BWYKEERLA1a7gv86Z4W23
2026-09-10 02:47:18 +00:00
Claude 102b0405b4 Drop family framing from anki-progress; add anki-deck-*.py tools
anki-progress.sh and its embedded app.py assumed a family/kids use case
(dashboard title, ntfy topic default, Authelia warning text, comments)
that was never actually stated — nothing in this repo should assume who
the accounts belong to. Retitled to plain "Anki Progress" throughout,
default ntfy topic changed from family-anki to anki-progress, and every
"family member" reference reworded to "account".

Also adds tools/anki-deck-math.py, tools/anki-deck-periodic.py, and
tools/anki-deck-visual.py — the Anki deck-generation scripts developed
earlier in this session, now committed as standalone, self-documented
tools (same tools/*.{sh,py} convention as tools/dedupe-finder.py) rather
than living only in chat. Each script's own header docstring carries the
full one-time setup (venv, genanki + piper-tts, downloading a voice) and
usage — anki-deck-periodic.py and anki-deck-visual.py point back to
anki-deck-math.py's copy rather than repeating it three times. Content is
generic (multiplication/division/addition/subtraction/fractions/decimals,
the periodic table, shapes/clocks/coin-counting) — nothing here assumes
who's using it or why.

Re-verified after the rename: the embedded app.py still passes its full
logic test suite once written out by the installer, and all three
tools/anki-deck-*.py scripts still build correct decks under
--dry-run-tts after their docstrings were rewritten.

Adds a README.md section pointing at the three scripts, and updates the
anki-progress Services table entry to drop "family" from its wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 19:34:48 +00:00
Claude 9c7a054d97 Add anki-progress service — family Anki study-progress dashboard
Read-only dashboard + ntfy notifications for an anki-sync-server
instance, following this repo's standard Docker-service template
(multi-instance, port scanning, DRY_RUN, update/fresh/cancel).

Dashboard shows reviews today/this week, accuracy, and current streak
per account. A background loop detects when a study session starts
(first review after a configurable inactivity gap, default 30 min) and
sends one ntfy notification a configurable delay later (default 10 min,
per request) if the session is still going — not on every review, and
not twice for the same session.

Reads every account's collection.anki2 with SQLite's read-only mode
(file:...?mode=ro) — never opens for write, so it can't corrupt or lock
out the live sync server or a syncing client. Verified this concurrently
against a real writer with no lock conflict, plus the streak/session/
notify-state logic against synthetic review timelines covering gapped
streaks, multi-session boundaries, and the no-duplicate-notification
requirement, before ever writing the installer around it.

Requires an anki-sync-server instance (hard dependency, checked at
install time, chains only that one direction per this repo's
"Chaining into another service" convention) and auto-detects a local
ntfy install to reach it directly over caddy_net instead of requiring
a public URL. Follows security-dashboard.sh's Authelia pattern: local
Authelia used automatically, remote Authelia offered otherwise, since
this exposes every family member's personal study activity.

Updates the Services table in README.md per the three-step rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 19:18:48 +00:00
Outis c63237a3db Merge pull request #447 from outis1one/claude/pensive-hopper-4c9e7i
Fix anki-sync-server container failing to start: data/ ownership
2026-09-09 12:01:00 -04:00
Claude 575c4ac185 Fix anki-sync-server container failing to start: data/ ownership
afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:
nonroot — the process always runs as that image's fixed nonroot UID/GID
(65532), never as ACTUAL_USER, and distroless ships no shell so nothing
inside the container can chown its own data dir at startup.

The installer's final chown gave the whole instance directory to
ACTUAL_USER, including ./data, which the container then can't write to
— it fails outright the moment it tries to create anything under /data
(e.g. a new user's collection), not just at sync time. Confirmed live.

Fix: re-chown ./data to 65532:65532 specifically, applied *after* the
existing ACTUAL_USER chown (not before — that call recurses over the
whole instance dir and would just clobber it). docker-compose.yml/.env/
README.md stay owned by ACTUAL_USER as before. The same fix is applied
in the "update" path so an already-broken existing install self-heals
on the next non-destructive update, without touching its port, Caddy
config, or accounts.

Verified live: fresh install now leaves data/ owned by 65532:65532
while the rest of the instance dir stays ACTUAL_USER; simulating a
pre-fix broken install (data/ owned by root) and running "update"
correctly repairs it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 15:50:10 +00:00
Outis a339d5fbb0 Merge pull request #446 from outis1one/claude/pensive-hopper-4c9e7i
Add anki-sync-server service — self-hosted Anki flashcard sync
2026-09-09 11:02:35 -04:00
Claude c2cf5bfe69 Add sync-account management menu to anki-sync-server
Re-running the installer against an existing install now offers "Manage
sync accounts" (add / remove / rotate a password) as a first-class menu
option, instead of requiring a hand-edit of .env kept in lockstep with
docker-compose.yml.

_anki_rewrite_account_block() regenerates the SYNC_USERn lines in both
files from the current account list, always renumbered contiguously from
1, and is shared by the initial install and every management mutation so
they can't drift apart. Only SYNC_USER/ANKI_SYNC_* lines are touched —
port, Caddy wiring, and everything else in either file is left alone.

Verified against a stubbed docker/ss sandbox: add, remove (mid-list, with
renumbering), and password rotation all produce the expected .env/
docker-compose.yml diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 14:58:32 +00:00
Claude 92f8503d48 Add anki-sync-server service — self-hosted Anki flashcard sync
Docker-based sync backend for the Anki app (afrima/anki-sync-server, the
official Rust sync server). Supports multiple independent accounts per
instance, the repo's multi-instance pattern, port collision avoidance,
Caddy wiring, and update/fresh/cancel reinstall detection.

No Authelia gate — this is a raw sync API the Anki client talks to, not
a browser session, so a forward_auth portal in front of it would just
break every sync request; SYNC_USER1/SYNC_USER2/... is its own auth
boundary.

Companion services/anki-sync-server.md covers client setup (Desktop,
AnkiDroid, AnkiMobile) and the Quizlet import/export walkthrough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DyceEVVeQ33EeS6C1PDv5
2026-09-09 14:07:31 +00:00
Outis d95bd7fd3c Merge pull request #445 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix inactivity sync being skipped when remember_me is unchanged
2026-09-09 09:45:38 -04:00
Claude 3cd9a1ece3 Fix _authelia_set_remember_me skipping inactivity sync on a no-op remember_me
The "already equal" early-exit compared only remember_me against the typed
value, so re-entering an unchanged remember_me (the exact case for anyone
who'd set it before the earlier fix existed) skipped the inactivity write
entirely, leaving inactivity stuck at its old mismatched value. Now only
skips when both keys already match the typed duration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-09 13:20:32 +00:00
Outis 5c13054cdd Merge pull request #444 from outis1one/claude/gitea-webhook-base-url-9ofg1n
Fix Authelia remember_me not actually keeping sessions alive
2026-09-09 09:16:51 -04:00
Outis 7d5674aad8 Merge pull request #443 from outis1one/claude/wolf-controller-setup-vl05t5
wolf: fix stale command list in the post-install summary, surface the…
2026-09-09 09:16:19 -04:00
Claude 2d82b2b278 Fix Authelia remember_me not actually keeping sessions alive
inactivity (idle timeout) was independent of remember_me and stayed at a
much shorter default (2h), so a long remember_me got silently overridden
by ordinary daily gaps between visits. install_authelia()'s template now
defaults inactivity to match remember_me, and the "Change remember me
duration" menu option now writes both keys together instead of just one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148pWopbt3tEKZWHYuHTb3c
2026-09-08 22:43:20 +00:00
Claude 08a2617b06 wolf: fix stale command list in the post-install summary, surface the Steam Input workflow
The final echo summary still advertised a removed `apps` command and left out
everything added since (cores, backup, controllers, steam-add-nonsteam-game,
steam-setup-frontends, cemu-clone-controller, cemu-sync-controllers,
install-completion, etc.). Also add a short pointer to the Steam-as-4-controller-hub
workflow (documented in depth further down in README.md) right in the install
summary, since it's currently only discoverable by reading the generated README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V99t5754SyXdnMTpVe2ba5
2026-09-07 22:29:55 +00:00
Outis 39387b5e0f Merge pull request #442 from outis1one/claude/wolf-cemu-four-controllers-373xn8
Claude/wolf cemu four controllers 373xn8
2026-09-05 15:00:15 -04:00
Claude e67ac50c61 wolf: document the ES-DE/RetroArch-in-Steam workflow in the generated README
The steam-setup-frontends command and the ES-DE/RetroArch AppImage
download step had no matching section in the ~/docker/wolf/README.md
content this file generates, unlike every other manage.sh command. Adds
one, alongside the existing Cemu-in-Steam section: why you'd want it,
how the mounts/cores are already shared, and the two honest caveats
(returning to Steam from ES-DE only works via this path, and whether
controller mappings sync across separately-paired Wolf clients is
expected but unconfirmed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-05 18:58:04 +00:00
Claude 3ebbeba672 wolf: add steam-setup-frontends to wait for Steam sign-in then auto-wire ES-DE/RetroArch
Steam Guard's QR-code sign-in can't be scripted (needs a phone approving
a prompt), so this polls for it instead: starts Wolf if needed, waits
for Steam's userdata/ to appear (or proceeds immediately if already
signed in), then re-invokes the existing steam-add-nonsteam-game command
for whichever of ES-DE.AppImage/RetroArch.AppImage was downloaded during
install. Points to ./manage.sh cores all for the shared cores/shaders/
overlays directory rather than duplicating that download logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:30:24 +00:00
Claude 07394769ca wolf: add optional ES-DE/RetroArch AppImage downloads for Steam Input
Both projects ship official standalone Linux AppImages separate from
the esde/retroarch Wolf catalog containers this repo already runs.
Adding either one to Steam as a non-Steam game (steam-add-nonsteam-game,
now reaching the same roms/saves/bios/retro-home/retroarch mounts as
the esde/retroarch containers) lets Steam Input assign a 4th controller
its own identity by device path, past Wolf's 3-concrete-pad-type ceiling.

ES-DE is hosted on GitLab rather than GitHub, so this adds a GitLab
Releases API counterpart to the existing GitHub-based download helper.
RetroArch's own buildbot doesn't publish through either API, so this
uses hizzlekizzle/RetroArch-AppImage, the community nightly-build
project the AppImage catalogs themselves point to (flagged as
third-party, same treatment this file already gives the Dolphin
community build). Both downloads are opt-in (default no) and symlink
to a fixed filename so steam-add-nonsteam-game's case-sensitive
substring match finds them regardless of the vendor's own asset name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:04:00 +00:00
Claude ae939c4085 wolf: mount ROMs/saves/BIOS/retro-home/retroarch into the Steam container
Lets a manually-downloaded ES-DE or RetroArch AppImage, added via
./manage.sh steam-add-nonsteam-game, see the same library, cores, and
ES-DE settings/custom systems (TI-99, Wii U) the esde/retroarch
containers already have, instead of starting from an empty config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 14:15:33 +00:00
Outis 28996eff57 Merge pull request #441 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix cemu-sync-controllers silently finding zero joysticks
2026-09-03 22:48:40 -04:00
Claude 93efe0d607 wolf: fix cemu-sync-controllers silently finding zero joysticks
docker exec needs an explicit -i flag to forward stdin into the
container process; without it, the heredoc piped into `python3 -`
never reached the containerized script, which ran empty and printed
nothing. cemu-sync-controllers then misread that empty output as
"SDL reported zero joysticks" — a false negative with a working set of
4 controllers already confirmed live in ES-DE, not an actual SDL or
GUID problem. Root-caused against the user's own live output: the
exact same probe script, run directly (not via the manage.sh command),
found real device nodes (js0-js4) present in the same container at the
same time cemu-sync-controllers reported zero.

Audited every other docker exec call in this file for the same
stdin-via-heredoc pattern; this was the only one missing -i.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:48:08 +00:00
Outis 79861c72f3 Merge pull request #440 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
2026-09-03 22:40:33 -04:00
Claude 2422ce1385 wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
cemu-clone-controller still needed one manual Cemu GUI step per new
device (click +, select it, map one button, Save) just to learn its
real uuid. This eliminates that too: instead of getting the uuid from
Cemu, it asks SDL directly (SDL_JoystickGetDeviceGUID/GetGUIDString via
ctypes against libSDL2 — the exact library Cemu itself links against)
for the live GUID of every controller connected to the active session,
then clones the proven-working mapping onto each one automatically.

Confirmed live end-to-end on the user's real box: querying SDL this
way inside a running ES-DE session reproduced the exact uuids Cemu had
already written by hand for two different controllers (Nintendo Switch
Pro and Xbox One S) — byte-for-byte identical to their real
controllerProfiles/controllerN.xml. This is what makes trusting SDL as
the uuid source safe, after an earlier attempt to reverse-engineer the
GUID's CRC16 portion by hand (tried 7 different CRC16 variants) failed
to match either device.

Also improved cemu-clone-controller's sibling: the synced profile's
display_name now comes from SDL's own live device name (when the probe
reports one) rather than always inheriting the mapping template's name,
which was a real but purely cosmetic issue caught while re-testing.

Verified end-to-end in an isolated harness against the real captured
controller0.xml/controller1.xml: idempotent skip on already-synced
slots (no needless backups, existing files left byte-identical), a new
slot correctly cloned with the live name and same 24-pair mapping set,
and valid XML output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:25:22 +00:00
Outis 6fc6c3b84d Merge pull request #439 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 20:52:47 -04:00
Claude e6522eadec wolf: add ./manage.sh cemu-clone-controller
Cemu's own Wolf-facing controller slots (up to 4) still each need a
separate manual bind through Cemu's Input Settings even after
./manage.sh controllers makes them distinct SDL devices — that dialog's
Save button is documented elsewhere in this README as getting cut off
the viewport, making binding all 4 slots by hand painful.

Validated the underlying assumption against two real, separately
hand-mapped profiles pulled live from a working install (Nintendo
Switch Pro on controller0.xml, Xbox One S on controller1.xml): both
contain the exact same 24 <mapping>/<button> pairs, just in a different
order — real proof Cemu's mapping format is device-agnostic and
order-independent, not just the README's prior unverified claim.

New command clones a proven-working <mappings> block onto a new
device's controllerN.xml, needing only that device's own real <uuid>
(which still has to come from Cemu itself — a hand-computed SDL GUID
risks not matching what SDL actually reports for the live device, so
this never guesses one). Getting that uuid only needs a single-button
minimal bind in Cemu's UI, not full mapping, since only the uuid gets
kept from it.

Verified end-to-end against the real captured controller0.xml/
controller1.xml content: cloning controller0's mappings onto
controller1's real uuid reproduces the exact same 24-pair set Cemu
itself wrote, and the output validates as well-formed XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 00:45:22 +00:00
Claude 2dcfaafc87 wolf: add Cemu TV-audio-stuck diagnostic block to the generated README
Copyable follow-up for the existing Cubeb/PulseAudio hang note: checks
whether a full Wolf restart has actually happened, whether PulseAudio
inside the Desktop container has any sinks at all, and pulls Cemu's own
log.txt plus its current <Audio> settings.xml block — narrows "stuck on
Disabled" down to a container-level audio problem vs. Cemu's own
device-switch path before guessing at a fix.

Verified the heredoc escaping by rendering the write_readme block through
a real bash heredoc and confirming $WOLF_DIR interpolates while every
other $ stays literal in the output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 19:23:20 +00:00
Outis 33e4f64d69 Merge pull request #438 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 14:58:11 -04:00
Claude cbfc28c2dd wolf: fix ES-DE ROMDirectory staying blank despite the self-healing patch
Root-caused against the user's own live output: the settings file had
<string name="ROMDirectory" value="" /> — the key was present, just
empty. The self-healing check only tested whether the string
"ROMDirectory" appeared anywhere in the file, so a key that exists with
a blank value (ES-DE can write this itself if its own first-run "select
ROM directory" step goes unanswered in a headless Moonlight session,
saving an empty path back over GOW's template) looked "already
populated" and got skipped — leaving ROM discovery broken even after
the fix had run.

Now ROMDirectory is independently forced to /ROMs whenever its value is
blank, on top of (not instead of) the existing RunInBackground patch —
no longer gated on the key's mere presence. Verified against the exact
reported bug (ROMDirectory present but blank) plus the existing missing-
file, already-correct, and idempotent-rerun scenarios in isolated /tmp
harnesses before touching the real script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:57:29 +00:00
Claude a212be09c3 wolf: add ./manage.sh steam-add-nonsteam-game, mount emulators/ into Steam
Adding an emulator (Cemu, etc.) to Steam as a non-Steam game previously
needed Steam's own Big Picture file-browser flow through Moonlight. This
writes the shortcut directly into Steam's binary shortcuts.vdf instead,
matching this repo's existing no-manual-wizard pattern (ge-proton,
install-ea-app). Motivated by wanting to test whether Steam Input's
per-device controller tracking (distinct device paths, not SDL GUIDs)
can hand Cemu 4 explicitly-assigned controllers where ES-DE/Cemu's own
SDL-based handling can't tell identical controllers apart.

- New generic binary VDF (KeyValues) parser/serializer: round-trips any
  existing shortcuts.vdf entries byte-for-byte and only inserts/replaces
  the one entry matching the given Exe path, so it's safe against a file
  that already has real, hand-configured shortcuts. Verified in isolated
  /tmp harnesses: fresh file, idempotent re-add, a second distinct entry,
  and preserving a synthetic pre-existing GUI-set entry (icon,
  LaunchOptions, tags) untouched.
- Steam's own CATALOG entry had no mount for emulators/ at all (unlike
  esde/retroarch) — added emulators:/home/retro/Applications so an
  AppImage is actually reachable from inside the Steam container. Like
  the ES-DE settings mount, this only takes effect on a freshly created
  WolfSteam container (Wolf reuses existing ones) — noted in the README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:52:31 +00:00
Outis 8a9241f0ff Merge pull request #437 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: make ES-DE RunInBackground fix fully self-healing, no manual step
2026-09-03 14:40:09 -04:00
Claude 5893346625 wolf: make ES-DE RunInBackground fix fully self-healing, no manual step
Previous version only patched an already-populated es_settings.xml,
requiring a manual "connect via Moonlight once" step before the fix
could apply — the user correctly pointed out the script can't know
whether that step has been done, so a manual dependency isn't a real
fix. This embeds GOW's own real es_settings.xml template (fetched from
games-on-whales/gow, RunInBackground pre-flipped false) and writes it
directly whenever the on-disk file is missing or is the old broken
stub (detected by the absence of ROMDirectory, which only a real
GOW/ES-DE-written file has). An already-populated file still only gets
the one RunInBackground line patched, leaving everything else alone.

Verified against all 4 scenarios in isolated /tmp harnesses before
touching the file: missing file, the exact broken stub from the user's
regression, GOW's real full template, and idempotent re-runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:35:44 +00:00
Outis 5847b81dfd Merge pull request #436 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 14:21:58 -04:00
Claude 52207598b9 Fix real regression: ES-DE Run-in-background patch broke ROM discovery
Confirmed live on a real install: after the previous commit, ES-DE found
zero games on every system. Root-caused by walking GOW's own
apps/es-de/build/scripts/startup.sh line by line: GOW's image bakes a
FULL es_settings.xml template (100+ settings, including
ROMDirectory=/ROMs, confirmed against the real template file in the
games-on-whales/gow repo) and its own startup script copies that
template into place exactly once, the very first time the container
starts, gated on the settings file not already existing.

The previous fix pre-created a minimal stub file (containing only
RunInBackground) before the container had ever started - GOW's startup
script saw that file "already there", skipped writing its own template
entirely, and ES-DE fell back to vanilla upstream defaults for
everything else in that file, including ROMDirectory, which is not
/ROMs by default. Every system's games vanished as a result.

Also corrects an earlier wrong claim in both the code comment and
README: ES-DE's own *compiled* default for RunInBackground is false, but
GOW's own template deliberately sets it to true - that's the real,
confirmed source of the original background-launch bug, not an
accidental toggle as previously assumed.

Fix: only ever patch an es_settings.xml that already exists with real
content (`[ -s "$_ESDE_SETTINGS" ]`) - never create one. On a genuinely
fresh install, before EmulationStation has ever launched once, the
installer now says so explicitly and defers rather than guessing, with
clear instructions to connect once and re-run. Verified against GOW's
actual real template: the patch flips just RunInBackground (line count
unchanged, ROMDirectory and everything else untouched), is idempotent on
re-run, and the skip-when-absent gate was verified directly against both
the missing-file and populated-file cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:15:50 +00:00
Claude c57f760fdc manage.sh controllers: collapse duplicate-paired devices to one entry
Live user feedback: being asked to pick between 6 numbered entries that
were all the exact same device (client_id repeated 6x from re-pairing)
was genuinely confusing, especially right when the user was already
trying to get to the controller-type poll further down the flow.

The picker now dedupes to distinct client_ids only, first-occurrence
order, tagging a collapsed entry "(paired Nx)" - matches what Wolf's own
get_client_by_id() actually resolves to anyway (first match for a given
id), so nothing is lost by not offering the later duplicates as separate
choices. Verified against the user's real 8-entry (6 duplicate + 2
unique) client list, both with and without an active session, confirming
the controller log-poll still runs correctly right after selection in
both cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:03:45 +00:00
Outis 57fd74f5af Merge pull request #435 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 13:57:42 -04:00
Claude 4ed7a9d2d5 Force ES-DE's "Run in background" off durably; mount ~/ES-DE/settings
Fixes a real, confirmed-live, cross-system bug: with ES-DE's "Run in
background (while game is launched)" enabled, ES-DE keeps listening to
every controller even after a game/emulator takes focus - a second
controller's input can reach ES-DE's own menu and launch a completely
different game underneath whatever's already running. ES-DE's own
USERGUIDE.md names this exact failure mode.

ES-DE's compiled default for this setting is already off (confirmed
against its real Settings.cpp), but nothing in this repo asserted that
durably, and ~/ES-DE (settings, gamelists, scraped media, logs) had no
mount onto the game drive at all - confirmed against ES-DE's own source
(getAppDataDirectory() is a plain $HOME/ES-DE) and GOW's own es-de
startup script. Wolf normally reuses the same app container across
sessions rather than recreating it each time (confirmed against Wolf's
docker.cpp - it only removes the container on exit if
WOLF_STOP_CONTAINER_ON_EXIT=TRUE, which this repo never sets), so this
mostly didn't bite day to day, but it meant the setting - and everything
else under ~/ES-DE - wasn't safe across an actual reinstall the way
roms/saves/BIOS already are.

Mounts esde-settings/ onto ~/ES-DE/settings specifically (additive,
doesn't touch the existing custom_systems mount) and writes a surgical,
idempotent RunInBackground=false into es_settings.xml on every install -
never a full rewrite, since that file is the user's own complete
settings state. Verified the write logic against fresh-file, existing-
file-with-other-settings, and flip-an-existing-true-to-false cases, plus
idempotent re-runs.

Gamelists/scraped-media durability under ~/ES-DE is a related, noted gap
- not fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 17:54:47 +00:00
Claude 9ba1d7e9db manage.sh controllers: poll Wolf's logs, label slots ordinally; document Cemu profile-editing recovery
Two follow-ups from live testing that just confirmed the whole
controllers_override chain works end to end:

1. manage.sh controllers now polls `docker compose logs wolf` for
   "Creating <TYPE> joypad for controller <N>" lines (most recent per
   slot wins) and shows what it last saw before asking anything, using
   it as the suggested default at each prompt. Every prompt is now
   labeled "1st controller"/"2nd controller"/etc - always meaning
   Wolf's own 0-indexed "controller 0"/"controller 1" - instead of
   asking for a raw slot number, closing the exact client-vs-controller
   0-vs-1 confusion surfaced live earlier. Documents plainly that this
   can show what TYPE a slot last used but can't show or control WHICH
   physical controller becomes which slot - that's decided by Moonlight
   client-side, upstream of Wolf's API entirely.

2. Documents a full recovery procedure in the Wii U section for when
   Cemu's Input Settings Save button is cut off-screen (confirmed live:
   happens even maximized, at higher resolutions, and closing the
   window doesn't save) - an Alt-drag/Alt+F7 window-manager fix first,
   then editing Cemu's own controllerProfiles/controllerN.xml directly
   on the host (plain XML, not inside the container) as a full GUI
   bypass. Documents the key insight that made this actually work live:
   <mapping>/<button> pairs are universal SDL_CONTROLLER_BUTTON_*
   semantics (confirmed earlier against real captured profiles from
   different controller brands), so a working mapping can be copied
   verbatim into a different device's empty <mappings/> block without
   redoing the live button-capture in Cemu's GUI at all.

Verified the redesigned manage.sh controllers flow end to end against a
mocked docker compose logs matching the user's real log output (both
the log-seen and no-logs-seen paths), plus the ordinal-suffix logic in
isolation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 17:44:53 +00:00
Claude 4b5ca9f6ea Disambiguate manage.sh controllers wording: client vs controller
Live user feedback: "Exactly one client is actively streaming right
now" sitting directly above "How many controller slots" read as "only
one controller is connected" - an easy misread since "client" (a paired
Moonlight session/device) and "controller" (a physical gamepad slot
within that session) are conflated by ordinary English but mean
completely different things here. The tool has no way to detect
controller count at all; it only ever picks which Moonlight
session/device to configure.

Reworded every message in this flow to say "Moonlight session/device"
instead of bare "client", and added an explicit line before the
controller-count prompt clarifying it's a separate question the user
has to answer themselves. Verified the full flow still runs correctly
end to end against the same mocked duplicate-heavy client data used
earlier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 16:23:02 +00:00
Outis 2517b31336 Merge pull request #434 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
manage.sh controllers: identify clients by live session IP, not opaqu…
2026-09-03 12:13:27 -04:00
Claude b671c1b2ec manage.sh controllers: identify clients by live session IP, not opaque ID
Confirmed live: the paired-clients list is a wall of meaningless
cert-derived numbers with nothing human-readable attached (verified
against Wolf's own PairedClient API schema - no name, no IP field
exists there at all), so there was no way to tell which entry was the
user's actual device. Made worse by Wolf not deduping repeated pairings
of the same device (the user's real box showed the same client_id 6
times).

Fix: cross-reference against GET /api/v1/sessions, which DOES carry
client_ip for every currently-streaming session, and tag each paired
entry as "ACTIVE - streaming from <ip>" when it matches one. When
exactly one client is actively streaming, it's now auto-selected with
no prompt - the practical case for a single-user setup. Falls back to
the manual picker when nothing's active or multiple clients are (now
showing which IP is which, so the choice is no longer a guess). Verified
against the user's real duplicate-heavy client list plus mocked active-
session data covering: single active session (auto-select), no active
sessions (manual fallback), and two distinct active sessions (manual
fallback with both IPs shown).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 16:03:43 +00:00
Outis d0c444e63f Merge pull request #433 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix manage.sh controllers: sudo needed for Wolf's root-owned API socket
2026-09-03 11:56:11 -04:00
Claude 0f89a3d534 Fix manage.sh controllers: sudo needed for Wolf's root-owned API socket
Confirmed live: a plain-user curl against /var/run/wolf/wolf.sock failed
with "Immediate connect fail ... Permission denied". The socket is
srwxr-xr-x root:root - Docker auto-creates the host-side /var/run/wolf
bind-mount source as root, and Wolf itself runs as root in its
container, so group/other never had write permission on the socket file
(required to connect() to a unix socket, not just read/traverse it -
`ls -la` on the containing dir working fine was a red herring).

Fix: sudo on both curl calls, matching how every other root-owned Wolf
state file ($WOLF_CFG) is already touched elsewhere in this script via
`sudo python3 -`. Documents the sudo prompt in the README too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:55:28 +00:00
Outis d84b958937 Merge pull request #432 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 11:51:29 -04:00
Claude 866d895357 Document exact controller types and the 4-controller case in README
Spells out what the three controllers_override values actually create
(Xbox One, PS5 DualSense, Switch Pro Controller - confirmed against
inputtino's own source, not just the enum names) instead of leaving the
reader to guess from bare AUTO/XBOX/PS/NINTENDO tokens, plus a note that
forcing a non-matching type only affects on-screen button-prompt
artwork, not functionality.

Adds a 4-controller section: Cemu's own Input Settings goes up to 8
slots and Wolf's wire protocol has no hardcoded 4-controller cap
(confirmed against control/input_handler.cpp), so the ceiling in
practice is the Moonlight client's own limit. With only 3 concrete
forced types available, a 4th controller can't get a guaranteed-unique
GUID - documents the one-at-a-time Cemu binding workaround for that
case, flagged as reasoned but not confirmed live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:46:10 +00:00
Claude 2005534b12 Add ./manage.sh controllers to fix multi-controller disambiguation
Root cause (confirmed against inputtino's own uinput device-creation
source, and against Wolf's real virtual-pad architecture): every
virtual gamepad Wolf creates of the same type gets an identical SDL
GUID, since a GUID identifies a controller model, not a physical
instance - the same behavior two real identical controllers would have.
This isn't a Cemu bug specifically; it's why any app whose own
controller picker keys off GUID (Cemu confirmed live) can't reliably
tell two same-type Wolf pads apart, driving every player from
controller 1.

Wolf's own per-client `controllers_override` setting sidesteps this at
the root by forcing each controller slot to a genuinely different pad
type (e.g. slot 1 = Xbox, slot 2 = PlayStation), so their vendor/product
IDs - and SDL GUIDs - actually differ. `./manage.sh controllers` drives
this via Wolf's real REST API (confirmed against its OpenAPI schema,
not its docs page's own stale example command) rather than hand-editing
config.toml: lists paired clients, prompts for a pad type per slot, and
POSTs the update. Needs Wolf's API socket exposed to the host
(WOLF_SOCKET_PATH + a /var/run/wolf bind mount, matching Wolf's own
documented pattern), added to the wolf service's docker-compose.yml.

Full flow verified with a standalone test harness (mocked curl against
real API response shapes, a real AF_UNIX socket file) covering: single-
vs multi-client selection, invalid selection, invalid slot count,
lowercase/unrecognized type input, and the missing-socket path.

Also documents live user feedback that AntiMicroX (added last session)
is confirmed not working for TI-99/4A - flagged in the README rather
than left silently unverified, with this fix noted as the actively
maintained path for the multi-controller problem instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:30:26 +00:00
Outis a3642c5159 Merge pull request #431 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Backfill PCEE2 as a PS2 alternative emulator in ES-DE
2026-09-03 11:18:40 -04:00
Claude 8aaaf993ac Backfill PCEE2 as a PS2 alternative emulator in ES-DE
The PCEE2 libretro core (a separate, actively-developed PCSX2 port,
WizzardSK/pcee2-libretro) is only in ES-DE's own bundled es_systems.xml
as of 3.5.0, which hasn't been released yet - confirmed against ES-DE's
own CHANGELOG.md and by diffing the v3.4.1 tag's real ps2 system block
against git master's (identical except for this one missing <command>
line). ghcr.io/games-on-whales/es-de:edge always installs the latest
*released* AppImage (confirmed against gow's own Dockerfile, which
queries GitLab's releases API), so the container this repo runs
genuinely has no PCEE2 entry at all yet - not a missing core download,
a missing menu entry.

Backfills it via the same esde-custom-systems/es_systems.xml mechanism
already used for the TI-99/4A and Wii U customizations, replicating the
real v3.4.1 ps2 block verbatim plus the one new command (as the default,
matching upstream's own placement). The core file itself needs no extra
handling - confirmed live against the libretro buildbot that
pcee2_libretro.so.zip is already covered by the normal RetroArch cores
pre-download.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:15:25 +00:00
Outis 09a24c2f16 Merge pull request #430 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 11:08:43 -04:00
Claude 0be27b15e9 Add opt-in AntiMicroX gamepad remapping, scoped to TI-99/4A and Wii U
AntiMicroX maps gamepad buttons to synthetic keyboard/mouse events -
useful here because ti99sim-sdl's own joystick handling only ever emits
digit keys 1-9 for a raw button (confirmed against its source), with no
path to 0/Enter/Q/Esc, and because it's a plausible angle on Cemu not
reliably telling apart two Wolf virtual pads that share an SDL GUID.

Scoped to just these two ES-DE systems via a second, separately-labeled
"(AntiMicroX)" <command> alongside each one's existing default - ES-DE's
own multi-command "alternative emulators" mechanism - rather than
touching every system. Each launches through a small wrapper script
that starts AntiMicroX hidden (--no-tray --hidden --eventgen uinput,
flags confirmed against AntiMicroX's own commandlineutility.cpp source)
against a profile the user builds themselves via AntiMicroX's own GUI in
the Desktop/XFCE app, execs the real emulator, and kills AntiMicroX on
exit.

AntiMicroX needs /dev/uinput to inject events under ES-DE's Sway/Wayland
session (its XTest backend needs Xwayland, not present here), so the
esde catalog entry now requests it via both GOW_REQUIRED_DEVICES and a
real device grant - and make_app_block/the reinstall-update path both
learn to read/refresh a per-app 'devices' field instead of always
emitting an empty array, so this actually reaches an existing install on
a rerun, not just a fresh one (verified live against a synthetic
pre-uinput-era config.toml).

Also fixes a latent whitespace-eating bug in every custom_systems
strip-and-reappend regex in this file (trailing \s*\n? reached into the
next sibling block's leading indentation once two custom systems
coexist in the same file) - cosmetic only, but it would have made every
rerun churn indentation between the ti994a and wiiu blocks. Caught and
verified via a standalone test harness exercising all four
strip-and-reappend blocks together across repeated passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 15:02:06 +00:00
Claude 9714bfca2e Document TI-99/4A controller limits and keyboard shortcuts in README
Joystick buttons beyond the first only reach digit keys 1-9 in
ti99sim-sdl's own source (src/sdl/ti994a-sdl.cpp) - there's no path to
0, Enter, Q, or Esc from a gamepad. Documents the real Esc-to-exit
behavior and full FCTN key row (Alt+1 through Alt+9, Alt+=), both
confirmed against the emulator's own bundled doc/README.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 14:39:48 +00:00
Outis 93288be7e2 Merge pull request #429 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 10:32:06 -04:00
Claude e3874b2ebe Launch TI-99/4A games fullscreen
ti99sim-sdl supports --fullscreen directly (confirmed via its own
--help output) — add it to the launch command so TI-99/4A games open
fullscreen the same way every other standalone emulator here does,
instead of a small windowed default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 14:30:39 +00:00
Claude 343c2ef68b Fix wrong return-to-launcher hotkey; document Cemu controller/audio setup
The README's "Return to launcher: Ctrl+Alt+Shift+W or START+UP+RB" was
wrong on both counts — confirmed live the controller combo did nothing.
The real, standard Moonlight client shortcuts (confirmed against
Moonlight's own documented shortcuts, not Wolf-specific) are
Ctrl+Alt+Shift+Q on a keyboard and Back/Select+Start+LB+RB pressed
together on a controller. Fixed in all three places this text appeared.

Also documented the actual working procedure for configuring a Cemu
controller and TV audio device, worked out live this session: both need
to be done from the Desktop (XFCE) app rather than ES-DE, since Cemu's
own Settings dialogs don't render correctly in ES-DE's Sway kiosk
session but do in a real XFCE window manager. Desktop shares the same
~/.config/Cemu as ES-DE, so the config carries over. Also notes the
real fix for Cemu's TV-audio-device hang (a full Wolf restart, not just
reconnecting) that worked in testing, and that /ROMs is now mounted in
Desktop too (previous commit) so Cemu can load games directly from
there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 06:15:59 +00:00
Outis 164d5e8891 Merge pull request #428 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Mount roms/saves into the Desktop app too
2026-09-03 01:50:20 -04:00
Claude 24fe5a3177 Mount roms/saves into the Desktop app too
The 'desktop' catalog entry only ever mounted emulators/ and the
persistent home — /ROMs didn't exist in that container at all.
Confirmed live: with the controller fix requiring a real XFCE session
to reach Cemu's own working dialogs, there was no way to actually load
a ROM once there (Cemu's own File/Load menu had nothing to browse to).
Add the same /ROMs and saves mounts es-de/retroarch already use, so a
standalone emulator run from Desktop can load games directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 05:43:32 +00:00
Outis 70f3bfbd85 Merge pull request #427 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 22:09:24 -04:00
Claude 2c93a2c7fd Fix unbound APP_KEYS variable in install_wolf()'s app injector
install_wolf()'s own copy of the app-injection step used $APP_KEYS instead
of its actual variable $_APP_KEYS (the underscore-prefixed name is
install_wolf()'s own; APP_KEYS is manage.sh's separate copy's variable).
Confirmed live: under 'set -u' without 'set -e' this was a non-fatal
"APP_KEYS: unbound variable" error printed mid-install right after "Wolf
restarted with updated config" — the script kept going, but the
Steam-storage-location success message never printed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 02:06:28 +00:00
Claude 5c3a38b9f0 Set APPIMAGE_EXTRACT_AND_RUN=1 on the Desktop app so AppImages can run
ghcr.io/games-on-whales/xfce has no libfuse2/libfuse3 at all (checked
against its own Dockerfile) and, unlike es-de's Dockerfile (which sets
this exact env var), no fallback either. Confirmed live: launching Cemu's
AppImage from a terminal inside the Desktop session failed outright with
the standard "AppImages require FUSE to run" error, even though the same
AppImage runs fine when ES-DE launches it — es-de's container already
carries this env var, xfce's doesn't. Adding it makes every AppImage
self-extract into a temp dir and run from there instead of trying to
FUSE-mount itself, matching how ES-DE already handles them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 21:53:38 +00:00
Outis 24b62b7415 Merge pull request #426 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 16:54:24 -04:00
Claude 52604b3ba6 Make app re-runs actually refresh a stale image/icon field
update_field() only ever got called for 'mounts' and 'env' when an app was
already present in config.toml, so the games-on-whales/desktop -> xfce
image-name fix from the previous commit would NOT have reached anyone who
already has a (broken) Desktop entry — re-running the installer or
./manage.sh apps would keep refreshing mounts/env but silently leave the
old, 404ing image reference in place forever.

update_field's own array-reformatting logic (scan forward for a closing
']') isn't safe to reuse for a single-line scalar field like image or
icon_png_path — there's no ']' on that line, so the scan would run into
an unrelated array further down the same block (e.g. 'ports = []') and
corrupt it. Added a separate update_scalar_field() that only ever
replaces the exact matched line, and wired it in for both 'image' and
'icon_png_path' in both copies of this app-injector script (install-time
and manage.sh's own 'apps' command) — verified locally against a
synthetic config.toml block that it replaces only the targeted app's own
fields and leaves a neighboring app's identically-named fields untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 20:51:59 +00:00
Claude 3d9df0793a Fix Wolf Desktop app's image reference: xfce, not desktop
ghcr.io/games-on-whales/desktop never existed. Confirmed live: Wolf logged
"[DOCKER] error 404 - No such image: ghcr.io/games-on-whales/desktop:edge"
and silently returned to the Moonlight app list with no other visible
error, making the Desktop tile look like it just didn't launch. The
games-on-whales/gow repo's apps/ directory names this app "xfce", and
ghcr.io/games-on-whales/xfce:edge is the real, currently published image
(confirmed against GHCR's own tag list for that package) — the icon path
uses the same "xfce" naming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 20:49:32 +00:00
Outis 14541062fc Merge pull request #425 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Share ES-DE's emulator/config mounts with the Wolf Desktop app
2026-09-02 16:35:38 -04:00
Claude d954c605b0 Share ES-DE's emulator/config mounts with the Wolf Desktop app
The 'desktop' Wolf app (ghcr.io/games-on-whales/desktop:edge, full XFCE
session) had mounts=[] — no access to emulators/ or the persistent
.config/.local/share home ES-DE/RetroArch use. That meant there was no way
to run a standalone emulator like Cemu outside ES-DE's single-app Sway
kiosk session, which matters because a second top-level window from an
app (e.g. Cemu's own Settings/Input dialogs) can fail to ever get mapped
or focused there — confirmed live: clicking Cemu's General Settings
highlighted the menu entry but no dialog ever appeared, with no way to
recover control short of killing ES-DE.

Give Desktop the same emulators -> ~/Applications and retro-home(-data)
-> .config/.local/share mounts esde/retroarch already use, so Cemu (or
any other standalone emulator) can be launched directly from a real
multi-window XFCE session instead, using the exact same settings.xml the
next ES-DE-launched session will read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 20:34:04 +00:00
Outis 5edfed7735 Merge pull request #424 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix TI-99/4A "emulator not found" by using a real es_find_rules.xml e…
2026-09-02 15:58:20 -04:00
Claude 910a49f12f Fix TI-99/4A "emulator not found" by using a real es_find_rules.xml entry
ES-DE's findEmulator() decides found-vs-not-found from the <command>
string's emulator token, and every real %INJECT%=...esprefix example in
ES-DE's own shipped es_systems.xml (Dolphin/PrimeHack/Triforce/Supermodel)
pairs it with an %EMULATOR_X%/%CORE_X% placeholder, never a literal path.
The previous ti994a <command> used a literal "/bin/bash -c ..." after
%INJECT%=%BASENAME%.esprefix, which ES-DE reported as "emulator not found"
even though /bin/bash obviously exists on the container.

Fix: give ti99sim-sdl a real custom_systems/es_find_rules.xml entry
(TI99SIM, staticpath ~/Applications/ti99sim-sdl) and reference it via
%EMULATOR_TI99SIM%, matching the pattern every built-in standalone
emulator uses. %STARTDIR%=%EMUDIR% replaces the old shell "cd && ..."
prefix to keep ti99sim-sdl's working directory at its own install dir,
where it looks up the console ROM via a plain relative path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:55:21 +00:00
Outis 22990b6583 Merge pull request #423 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix TI-99/4A ES-DE command line never actually updating on re-run
2026-09-02 15:38:02 -04:00
Claude b4ac5a4de0 wolf: fix TI-99/4A ES-DE command line never actually updating on re-run
Confirmed live: the command-line fix from an earlier commit (cd into
emulators/ before launching ti99sim-sdl) never reached the user's actual
es_systems.xml, because the write step's guard was "skip entirely if a
ti994a entry already exists" — which it did, with the old pre-fix
command line still in it. The check only asked whether an entry existed,
never whether its content matched the current template.

Now always strips any existing ti994a <system> block and re-adds the
current one fresh on every run, via a small python3 rewrite (regex block
removal + re-append) instead of a blind append-once guard. Tested against
a stand-in file matching the real stale content, and for idempotency
(second run doesn't duplicate the block), before pushing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:28:28 +00:00
Outis daf0ed11e4 Merge pull request #422 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 15:17:36 -04:00
Claude 0fe0c74235 wolf: fix Dolphin Sys folder writing to the wrong directory
Confirmed live: RetroArch's system_directory on this Wolf setup is
"~/bioses" (GoW's own shipped default, confirmed by reading a real
retroarch.cfg directly — wolf.sh never sets this itself), not RetroArch's
usual default of ~/.config/retroarch/system. The Dolphin Sys-folder
automation added earlier this session assumed the usual default and wrote
to retroarch/system/dolphin-emu/Sys on the host — a path RetroArch's own
config never actually reads, so it would have sat there doing nothing.

Now reads the real configured system_directory value out of
retroarch.cfg instead of assuming, and self-heals by moving a Sys folder
that's already sitting in the old wrong location (from before this fix)
into the correct one. Also corrects the same wrong assumption in the
MAME samples-directory documentation, which pointed at the same
retroarch/system/ default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:16:05 +00:00
Claude 2e7e073b63 wolf: fix TI-99/4A console ROM location + add it via a tab-completing prompt
The build succeeded, but the follow-up message pointed at "ti99sim-sdl's
own README" for where the console ROM goes — useless, since that source
tree lived inside the throwaway build container and was already gone by
the time anyone could read it.

Got the real answer from RetroPie's own configure_ti99sim(): it symlinks
the console ROM into the emulator's own install directory, then cd's
there before launching (pushd "$md_inst" && ./ti99sim-sdl "$@") — so
ti99sim-sdl finds it via a plain relative lookup, not any search path.
Caught a real bug this exposed: our own ES-DE <command> line launched
ti99sim-sdl by full path with no cd, so even a correctly-placed file
would never have been found. Fixed to cd into emulators/ first, matching
RetroPie's own pattern exactly.

Also, instead of just printing the required path (emulators/TI-994A.ctg)
and leaving it at that, now prompts for it directly with a tab-completing
path picker (read -e -i, defaulting into the game storage dir) — if you
already have the file somewhere, it copies it into place immediately
instead of making you do it by hand afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 19:11:13 +00:00
Outis f27fdad711 Merge pull request #421 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-02 14:58:43 -04:00
Claude e965c2bd76 wolf: build ti99sim-sdl from the real SDL2-native upstream, not a stale fork
You were right to push back on this earlier — the original ask was SDL2,
same as RetroPie, and I substituted a different thing (SDL1-compat) without
checking what RetroPie actually does first.

Checked RetroPie's own ti99sim.sh scriptmodule directly: it doesn't build
from billzajac/ti99sim (the GitHub fork the last two attempts used) at
all. It fetches upstream v0.16.0 straight from the original author's own
site (mrousseau.org), applies exactly one trivial patch (a missing
#include <cstring> for modern g++), and builds against libsdl2-dev +
libssl-dev directly — no SDL1 compatibility layer, because that source
genuinely supports SDL2 natively. The GitHub fork used previously was
just a stale, pre-SDL2-port copy under a different account.

Verified the download URL actually serves the real tarball (fetched real
binary content, confirmed the 7zXZ header) and the patch's exact content
before using either — same real-source-first standard as the wolf.sh
fixes earlier this session. Tested the sed insertion that replaces
applying the .diff against a stand-in file matching the patch's real
context before trusting it.

Also documents the exact BIOS-equivalent filename RetroPie's own docs
give for this emulator: TI-994A.ctg (case-sensitive) — a real, useful
detail that was missing before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:47:07 +00:00
Claude c7cc7176f0 wolf: fix ti99sim-sdl build — it needs real SDL 1.2, not SDL2
Confirmed live: the previous sdl-config shim got past the tooling
problem, but hit a much deeper one — ti99sim's SDL frontend
(src/sdl/main.cpp) is written against the actual SDL 1.2 API and uses
symbols SDL2 genuinely removed (SDL_keysym, SDL_WM_SetCaption,
SDL_EnableUNICODE, SDL_JoystickOpened, the old integer-index
SDL_JoystickName). No shim can paper over missing symbols — this needed
the real thing.

Swapped libsdl2-dev + the hand-rolled sdl-config shim for
libsdl1.2-compat-dev — the SDL project's own official compatibility
package (libsdl-org/sdl12-compat, packaged in Ubuntu's universe repo):
genuine SDL 1.2 headers and its own real sdl-config, implemented on top
of actual SDL2 underneath. The code compiles against the API it was
actually written for, while still running on modern SDL2 at runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:40:55 +00:00
Outis ad5440a58b Merge pull request #420 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix ti99sim-sdl build failing on missing sdl-config
2026-09-02 14:36:20 -04:00
Claude a55f6c430a wolf: fix ti99sim-sdl build failing on missing sdl-config
Confirmed live: the containerized ti99sim-sdl build (previous commit)
failed with "SDL.h: No such file or directory" — not a real
compatibility problem, ti99sim's own Makefile.linux calls the old SDL 1.x
sdl-config tool, which doesn't exist in modern libsdl2-dev packaging at
all (only pkg-config .pc files). Every sdl-config invocation was silently
returning nothing, so the compile got no -I/-L flags whatsoever.

Adds a small shim mapping the handful of flags this build actually uses
(--cflags/--libs/--version, tested standalone against a fake pkg-config
before pushing) onto `pkg-config sdl2`, rather than patching the
project's own Makefiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:32:35 +00:00
Outis 3b0689dfd9 Merge pull request #419 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: actually build ti99sim-sdl instead of punting it to a manual step
2026-09-02 14:28:41 -04:00
Claude 83d62b05fd wolf: actually build ti99sim-sdl instead of punting it to a manual step
Fixes a cop-out from the previous commit — rather than just warning about
a possible glibc/SDL2 mismatch and handing the user a manual build guide,
build ti99sim-sdl inside a throwaway container running the exact same
image ES-DE itself runs (ghcr.io/games-on-whales/es-de:edge), which
actually eliminates the mismatch risk instead of just noting it. Runs
during install_wolf() when the TI-99/4A prompt is accepted, skips itself
if the binary's already present, and drops the result straight into
emulators/ti99sim-sdl ready to use.

Caught a real bug before pushing, not after: `make` alone leaves the
binary under ti99sim's own src/ directory, not the repo root (confirmed
against its README), so a naive `cp ti99sim-sdl` would have failed
outright. Uses `make install` instead — the project's own reliable way to
collect the binary (copies to /opt/ti99sim/bin, symlinks into
/usr/local/bin) — rather than guessing the exact build subpath.

The one piece that's still genuinely a manual step, and stays that way:
supplying your own TI-99/4A console ROM + GROM dump, real copyrighted
console firmware no installer can legally source for you.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:22:40 +00:00
Outis 423b295acf Merge pull request #418 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: add Cemu (Wii U), TI-99/4A custom ES-DE system, BIOS/MAME-sampl…
2026-09-02 14:16:59 -04:00
Claude 25dc4251de wolf: add Cemu (Wii U), TI-99/4A custom ES-DE system, BIOS/MAME-samples docs
Cemu: no libretro core exists (a third-party attempt was never merged and
there are no plans to per RetroArch's own issue tracker) — added as a
fourth standalone AppImage download, same pattern as Azahar/PCSX2/Dolphin.
Its official release asset is already named Cemu-<version>-x86_64.AppImage,
matching ES-DE's own find-rule directly, so no Dolphin-style rename/symlink
is needed. Flags a real supply-chain compromise of Cemu's own Linux release
assets around v2.6 (2026-05, since restored, per Datadog Security Labs)
and that most retail games need a user-supplied Wii U common key.

TI-99/4A: added as a genuine ES-DE system (not this repo's existing
js99er service, which is browser-based and can't be launched as an ES-DE
system) via a custom_systems/es_systems.xml, per ES-DE's own documented
mechanism for extending its built-in system list. Needed a new mount
(esde-custom-systems -> ~/ES-DE/custom_systems) since that path wasn't
covered by any existing .config/.local/share mount. The emulator itself
(ti99sim-sdl) is deliberately NOT auto-built — it ships no AppImage, and
compiling it on the host risks a glibc/SDL2 mismatch against the ES-DE
container's own runtime that isn't safe to guess at blind — so this
writes the system definition (cheap, correct, always useful) and gives
manual build+placement instructions instead of a build automation I can't
verify actually runs.

Also expands the BIOS callout to mention PS2 (configured inside PCSX2's
own settings, not auto-detected from bios/) and TI-99/4A (needs a real
console ROM+GROM dump, same legal situation as any other BIOS), and
documents the MAME sample-pack-vs-ROM confusion (identical .zip naming
convention makes them indistinguishable by filename alone; samples belong
in retroarch/system/<mame-core>/samples/, not roms/mame/).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 18:04:16 +00:00
Outis 0a32ab7844 Update README to remove unnecessary exclamation
Removed redundant phrase 'Great!' from README.
2026-09-02 13:25:42 -04:00
Outis 24e59a280c Enhance README with positive remark
Updated README to include positive feedback.
2026-09-02 13:25:13 -04:00
Outis 253ee7587b Merge pull request #417 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix info.zip pack never actually installing (broke content matc…
2026-09-02 11:41:11 -04:00
Claude 505a342417 wolf: fix info.zip pack never actually installing (broke content matching for every core)
Confirmed live: RetroArch's file browser and directory-scan importer both
failed to recognize SNES ROMs (.sfc) even though the ROM files and the
snes9x core .so were both genuinely present and correctly mounted — the
core's own .info file (which declares supported_extensions) was never
actually installed, because the "already present, skip" check for the
info.zip pack was "is $CORES_DIR non-empty", and $CORES_DIR is always
non-empty by the time that check runs (the core .so files fill it first,
earlier in the same `cores all` run). So the info-pack fetch silently
no-op'd on every single install, fresh or not — not specific to this
laptop.

Give "info" its own real check: does $CORES_DIR actually contain any
*.info files, not just anything at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-02 15:38:51 +00:00
Outis ddfae32987 Merge pull request #416 from outis1one/claude/gitea-github-webhook-sync-7fkytg
Document the GitHub App option in the generated Gitea README
2026-09-02 08:53:31 -04:00
Claude 1b4036a0c2 Document the GitHub App option in the generated Gitea README
Adds Option B (a personal GitHub App installed with "All repositories"
access) alongside the existing per-repo webhook instructions, and
clarifies that Homepage URL and Webhook URL are separate fields on the
App creation form -- a real point of confusion when setting one up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oxpDzv7qfV7RDvKHp1sPD
2026-09-02 12:50:14 +00:00
Outis a49f8c3533 Merge pull request #415 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-01 19:41:38 -04:00
Claude c9d1eac7f2 wolf: fix unbound-variable crash on a single-drive box with no unmounted disks
Confirmed live on a laptop with only its internal drive (no second/
unmounted disk at all): the game-storage-directory picker's `local -a
_UNMT_DEV _UNMT_LABEL _UNMT_UUID` declares the arrays but, when the lsblk
scan finds zero qualifying unmounted block devices, never actually assigns
an element to any of them. Under setup.sh's `set -u`, that's enough for a
later read (`${#_UNMT_DEV[@]}`) to throw "unbound variable" even though
the arrays were properly `local -a` declared — a known bash nounset quirk
this repo has already hit and documented once before (see
vendor/ai-stack/configure-searxng-safesearch.sh). Explicit `=()`
initializers side-step it. The sibling `_CAND_*` arrays a few lines above
don't need the same fix — they always get at least one element (the home
directory option is unconditional), so they can't hit this path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-01 23:41:15 +00:00
Claude 7ed376e9b7 wolf: auto-download shaders/overlays/cheats/database/autoconfig alongside cores
Extends `./manage.sh cores all` (and install_wolf()'s own cores prompt,
which calls it) to also pull the rest of what RetroArch's own Online
Updater offers — Slang shaders, overlays/bezels, cheat files, the RDB
game database, and controller autoconfig profiles — directly from the
same libretro buildbot the cores themselves come from
(buildbot.libretro.com/assets/frontend/). Confirmed live against the
real directory listing and each zip's actual internal structure (flat,
no wrapping folder) before writing the extraction paths, rather than
guessing: ~290 MB total, landing in retroarch/{shaders,overlays,cheats,
database,autoconfig}/ using RetroArch's own default paths for the three
config keys (cheat_database_path, content_database_path,
joypad_autoconfig_dir) this install leaves unset.

Thumbnails are deliberately excluded — they're hosted separately, are
per-system, and can run into many GB, so blindly grabbing "all systems"
would be a bad default; documented as a manual per-system pull via
RetroArch's own Thumbnails Updater instead.

Idempotent like the existing cores loop: skips a pack whose destination
directory already has content, unless `force` is passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-01 22:29:05 +00:00
Outis 435992a4f0 Merge pull request #414 from outis1one/claude/gitea-github-webhook-sync-7fkytg
Allow typing a password when adding/resetting Authelia users
2026-09-01 15:44:57 -04:00
Claude 9d3801494a Allow typing a password when adding/resetting Authelia users
add_authelia_user() and the per-user "Reset password" action always
auto-generated a random password with no way to set a specific one.
Adds _authelia_prompt_password(), a shared masked-input prompt (same
"[Enter = auto-generate]" convention already used by backup.sh/
borg-backup.sh/koha.sh) that both call sites now use, so an admin can
type their own password or fall back to auto-generation as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oxpDzv7qfV7RDvKHp1sPD
2026-09-01 19:26:29 +00:00
Outis eb794e61f9 Merge pull request #413 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: auto-fetch Dolphin's Sys folder, drop AntiMicroX, favor RetroAr…
2026-09-01 15:25:41 -04:00
Outis 5ace213bd4 Merge pull request #412 from outis1one/claude/gitea-github-webhook-sync-7fkytg
Add real-time GitHub webhook sync to Gitea
2026-09-01 15:24:34 -04:00
Claude 1d386e6a58 wolf: auto-fetch Dolphin's Sys folder, drop AntiMicroX, favor RetroArch core for GC/Wii
Confirmed live: the Dolphin libretro core needs its own 'Sys' folder
(compatibility DB + IPL data) to boot Wii titles, previously only
obtainable through a manual trip through RetroArch's Online Updater ->
Core System Files Downloader. ./manage.sh cores now fetches it
automatically (a sparse/shallow git checkout of dolphin-emu/dolphin's
Data/Sys) right after the core itself downloads, so a fresh install needs
no GUI step to get GameCube/Wii working.

Also documents the one Dolphin quirk that isn't installer-fixable: a
"This data is corrupted, delete and create a new one?" dialog with a
greyed-out OK button on a specific title (confirmed live with Mario Kart
Wii's own rksys.dat) is that game's own save data getting stuck, not a
setup defect — the fix is deleting that one file, which the README now
walks through directly instead of leaving it as a mystery.

Removes the AntiMicroX controller-combo plumbing entirely: RetroArch's own
universal hotkey binds already cover this for any core-based system
(GameCube/Wii and PS2 both now go through RetroArch's own cores rather
than standalone AppImages by default), and AntiMicroX grabbing the
controller device exclusively caused real problems (a stuck "controller
connected" screen) without enough benefit over RetroArch's native binds to
justify keeping it.

Reframes the GC/Wii/PS2 docs: RetroArch's cores are now the recommended
path (point ES-DE's Alternative Emulators at RetroArch instead of "Dolphin
(Standalone)"), with the standalone AppImages kept as a fallback and as
the only option for 3DS (Azahar has no libretro core at all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-01 19:22:49 +00:00
Claude 778d06b0b8 Add real-time GitHub webhook sync to Gitea
Gitea's GitHub mirror sync previously only ran on a fixed-interval
timer. Adds an opt-in GitHub webhook receiver (small stdlib-only
Python HTTP server under its own systemd service) that verifies
GitHub's HMAC-SHA256 signature and triggers an immediate, single-repo
--pull-only sync the moment GitHub receives a push, wired through
Caddy the same way every other service in this repo is. The scheduled
timer stays in place as a safety net and still covers the Gitea ->
GitHub direction.
2026-09-01 16:27:11 +00:00
Outis 63faa9b1bd Merge pull request #411 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-01 11:06:39 -04:00
Claude 4199f42f70 wolf: fix wrong-architecture AppImage downloads (aarch64 picked on x86_64 hosts)
_wolf_download_emulator_appimage picked whatever release asset happened to
be first in the GitHub API's asset list among files ending in .AppImage,
with no architecture filtering. Confirmed live: pkgforge-dev's Dolphin
AppImage release lists an aarch64 build ahead of the x86_64 one, so the
downloaded file silently failed at launch with a bare "exec format error"
and no indication why — the earlier "emulator not found" symlink fix
(previous commit) got ES-DE to find the file at all, which is what
surfaced this as the next failure.

Now prefers whichever release asset's filename actually tags the host's
own architecture, falls back to an untagged asset (Azahar/PCSX2's releases
carry no arch tag and are unaffected either way), and only then falls back
to "take the first one". Also verifies the downloaded file's real ELF
architecture against the host post-download and warns loudly if it still
doesn't match, since filename tagging isn't something every release can be
trusted to get right.
2026-09-01 15:03:57 +00:00
Claude 2c51ab5faa wolf: symlink downloaded Dolphin AppImage to the name ES-DE actually looks for
ES-DE's es_find_rules.xml DOLPHIN entry only auto-detects a file literally
named Dolphin_Emulator*.AppImage under ~/Applications. pkgforge-dev's own
community-build release asset isn't named that, so the AppImage downloaded
by install_wolf() sat in emulators/ unnoticed and ES-DE reported "Couldn't
launch game, emulator not found" (%EMULATOR_DOLPHIN% unresolved) even
though the file was right there — confirmed live on a real box.

Drop a same-directory Dolphin_Emulator.AppImage symlink pointing at
whatever the real download is named, without renaming/losing the vendor
filename. Runs unconditionally (not just after a fresh download) so it
also repairs installs that grabbed the file before this fix existed.
2026-09-01 14:37:01 +00:00
Outis 6dff335ac7 Merge pull request #410 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh: mount /home/retro/.local/share, not just .config
2026-09-01 09:52:49 -04:00
Claude 85b13d07a7 Fix wolf.sh: mount /home/retro/.local/share, not just .config
Confirmed live: Dolphin's "data is corrupted" / "could not write
to/read from Wii system memory" was ~/.local/share/dolphin-emu (the
emulated Wii NAND) not existing at all inside the container —

  ls: cannot access '/home/retro/.local/share/dolphin-emu/':
  No such file or directory

Only /home/retro/.config had a persistent mount (retro-home, added
earlier this session for the RetroArch permission-denied fix) —
/home/retro/.local/share, the other half of the XDG base directory
split many apps use (config vs. actual data), was never addressed and
started completely empty on every container launch. Any NAND state
Dolphin wrote mid-session vanished the moment that container was torn
down, which is consistent with "corrupted" rather than merely "empty"
if a session ended mid-write.

Same fix as the earlier .config mount: a new retro-home-data host
directory mounted at /home/retro/.local/share in both the esde and
retroarch app profiles (both CATALOG copies), forced to 1000:1000
ownership like the other retro-home dirs since the container's retro
user is hardcoded to that uid regardless of the host account.
2026-09-01 13:49:05 +00:00
Outis ce9a8e8c5b Merge pull request #409 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-08-31 22:45:22 -04:00
Claude bef88e654d Fix two bugs in the AntiMicroX plumbing before it ever runs
1. Glob case mismatch that would have silently no-op'd the whole
   feature: the real GitHub release asset is AntiMicroX-x86_64.AppImage
   (capitalized) — confirmed against the actual release, not assumed.
   Both the helper's own already-downloaded check and this file's own
   post-download lookup used a lowercase-only "antimicrox*.AppImage"
   glob (copied from Azahar's/PCSX2's pattern, which really are
   lowercase). Linux glob matching is case-sensitive, so _AMX_APP would
   have come back empty even after a successful download, hitting the
   "didn't produce a usable AppImage" branch and silently skipping the
   Sway launch hook with the AppImage sitting there unused.

2. Sway match criteria used app_id, which only matches native Wayland
   clients. AntiMicroX is a Qt5/X11 app running under XWayland here —
   same as ES-DE and Steam already are in this exact stock config,
   which is why their own for_window rules use [class="..."], not
   app_id. Switched to class (and title as a fallback, in case the
   exact WM_CLASS AntiMicroX registers differs), with a (?i) case-
   insensitive prefix — confirmed sway uses PCRE2 for criteria matching
   and (?i) is the documented syntax for exactly this, not assumed.

Neither bug was fatal to Wolf itself (both fail quietly rather than
crashing anything), but both would have cost real troubleshooting time
for something that looked like it should just work.
2026-09-01 02:32:45 +00:00
Claude f087984526 Add AntiMicroX plumbing for a universal controller-combo hotkey
Wires up the infrastructure for RetroArch's own hotkeys (global across
every libretro core already) to be matched by an equivalent for
Dolphin/PCSX2/Azahar, none of which support gamepad-bound hotkeys
natively (confirmed open feature requests: PCSX2/pcsx2#1082,
azahar-emu/azahar#722; Dolphin's are hardcoded to keyboard only).

AntiMicroX watches Wolf's virtual gamepad directly — a real uinput
device per Wolf's own "inputtino" docs, not a proprietary channel —
and injects whatever keyboard shortcut the focused emulator actually
expects.

Mechanism: GoW's own stock Sway config (baked into the esde/retroarch
images) has a deliberate first-line extension point,
`include /home/retro/.config/sway/custom-cfg` (confirmed by reading it
directly out of a running container). Since /home/retro/.config is
already the retro-home mount added earlier this session, writing a
file to $GAME_STORAGE_DIR/retro-home/sway/custom-cfg on the host lands
exactly there in both containers — zero changes to GoW's own image or
scripts needed.

Deliberately scoped to plumbing only: download AntiMicroX (reusing the
_wolf_download_emulator_appimage helper added for PCSX2/Dolphin), add
the Sway launch hook (floating window, not hidden, so it's reachable
through the Moonlight stream), and document the actual one-time setup
flow in the generated README. The button mapping itself, including
AntiMicroX's own per-application Auto Profile switching, is built
through its real GUI rather than a hand-authored .gamecontroller.amgp
this repo can't verify blind.

Marked experimental throughout (install prompt defaults to n, DRY-RUN
summary, README) — this is unverified against a live session.
2026-09-01 02:24:29 +00:00
Claude 1ed7fe89ea Add PCSX2 and Dolphin to wolf.sh's auto-download emulators
Factored the existing Azahar-only download block into a reusable
_wolf_download_emulator_appimage() helper (same fetch-latest-release-
and-find-.AppImage-asset logic, parameterized), then reused it for:

- PCSX2 (PS2) — officially publishes Linux AppImages via GitHub
  Releases (PCSX2/pcsx2), same pattern as Azahar.
- Dolphin (GameCube/Wii) — dolphin-emu.org's own Linux distribution
  is Flatpak-only, no official AppImage at all. Uses the well-regarded
  community AppImage build (pkgforge-dev/Dolphin-emu-AppImage)
  instead, with explicit warnings before the prompt and in the
  generated README that this is a third-party build, not an official
  Dolphin release — so the user can decide knowingly rather than this
  silently substituting an unofficial build for what looks like an
  official option.

Motivated by a live "could not write to/read from Wii system memory"
error — GameCube/Wii isn't handled by a RetroArch libretro core in any
stable way (Dolphin's libretro core is unstable for Wii specifically),
so ES-DE needs a real standalone Dolphin binary to hand those systems
off to, the same way it already does for 3DS via Azahar.

Also updated the DRY-RUN summary and the generated README's emulators
section to describe all three instead of just Azahar.
2026-08-31 23:44:24 +00:00
Outis 7dfcb8272a Merge pull request #408 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh: mount /home/retro/.config itself, not just .config/retro…
2026-08-31 18:03:32 -04:00
Claude 0ba83212d1 Fix wolf.sh: mount /home/retro/.config itself, not just .config/retroarch
Only .config/retroarch was mounted, so Docker still had to auto-create
.config itself (one level up) as an intermediate directory for that
bind mount to attach under — same root:root mode-755 stub problem as
before, just one level higher. Confirmed live: RetroArch got past its
own config.toml write this time, but then crashed on:

  mkdir: cannot create directory '/home/retro/.config/waybar': Permission denied

(Waybar/Sway — the on-screen overlay GoW's app-runner wrapper spawns
for every app, not RetroArch-specific.)

Fix: add a second mount at /home/retro/.config itself, backed by a new
retro-home/ host directory, alongside the existing more-specific
.config/retroarch mount (Docker resolves nested/overlapping bind mounts
correctly regardless of list order). Now nothing under .config is a
Docker-auto-created stub — it's real host-backed content throughout,
so anything an app writes directly under .config (Waybar, Sway state,
RetroArch's own non-cores/shaders/overlays files) just works.

Also force retro-home/ and the existing retroarch/ dir to 1000:1000
explicitly after the general ACTUAL_USER chown — the GoW app
containers' 'retro' user is hardcoded to uid 1000 regardless of what
uid the host account installing this actually has, so relying on the
general chown alone silently breaks this fix on any box where those
don't match.
2026-08-31 21:21:53 +00:00
Outis d72c638337 Merge pull request #407 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh update_field(): scope field search to the app's own block
2026-08-31 16:46:01 -04:00
Claude ed939e5826 Fix wolf.sh update_field(): scope field search to the app's own block
update_field() searched a blind +/-25-line window around an app's
name = '...' line to find and rewrite its mounts/env field. Once app
blocks got shorter (e.g. after collapsing a 3-line mounts array into
one line), two adjacent apps' blocks could end up close enough that
the window reached into a neighboring app's block instead — splicing
that block's own field or, worse, eating into its
[profiles.apps.runner] table header.

Confirmed live: this corrupted config.toml into invalid TOML and
crash-looped Wolf outright:

  ERROR | Unhandled exception: Error while parsing table header:
  cannot redefine existing table 'profiles.apps.runner'

Fix: bound the search to the enclosing [[profiles.apps]] block only —
walk backward from the name line to the nearest [[profiles.apps]]
header, forward to the next [[profiles.apps]] or [[profiles]] header,
and only look for the field within that range. Never crosses into a
neighboring block regardless of how short either one is.
2026-08-31 20:45:18 +00:00
Outis bc7b9c6bb0 Merge pull request #406 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Fix wolf.sh manage.sh apps: detect installed apps regardless of inden…
2026-08-31 16:24:48 -04:00
Claude c1a97d8945 Fix wolf.sh manage.sh apps: detect installed apps regardless of indentation
INSTALLED=$(sudo grep "^    name = 'Wolf" ...) required exactly 4 leading
spaces before name = 'Wolf...' in Wolf's own generated config.toml.
Confirmed live: Wolf's TOML writer doesn't reliably indent that way, so
this came back empty even with apps clearly installed and running,
printing "No Wolf apps installed yet". That's silently wrong in two
ways: the "Already installed: ..." message under-reports, and pressing
Enter at the apps prompt ("update mounts only") falls through to the
hardcoded `steam esde` default instead of actually refreshing whatever
was already there — so a mount fix for an already-installed app (e.g.
RetroArch) never gets applied by the Enter/no-picks path at all.

Relaxed the anchor to tolerate any amount of leading whitespace,
matching the Python injector's own already-installed check a few lines
later, which never anchored on indentation to begin with.
2026-08-31 20:21:36 +00:00
Outis 0d8d87794d Merge pull request #405 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-08-31 16:11:29 -04:00
Claude 76a494bcbf Fix wolf.sh: mount whole retroarch/ dir, not just its subdirectories
The esde and retroarch app profiles (both CATALOG dict copies) mounted
three subdirectories individually:

  {games}/retroarch/cores:/home/retro/.config/retroarch/cores:rw
  {games}/retroarch/shaders:/home/retro/.config/retroarch/shaders:rw
  {games}/retroarch/overlays:/home/retro/.config/retroarch/overlays:rw

but never mounted /home/retro/.config/retroarch itself. Docker
auto-creates that missing parent directory inside the container as
root:root mode 755 (standard behavior for a bind-mount target that
doesn't already exist in the image) — the retro user (uid 1000) can
read/traverse it but not write into it. RetroArch's own entrypoint
then fails outright trying to write its default config there:

  cp: cannot create regular file '/home/retro/.config/retroarch/retroarch.cfg': Permission denied

which happens on every single launch, for both apps — confirmed live
against a real box: the container starts, RetroArch dies on that cp
within ~1s, and Wolf tears the session down (the "black screen, back
to app grid" symptom, with nothing RetroArch-specific about it).

Fix: mount the parent {games}/retroarch directory itself onto
/home/retro/.config/retroarch instead of three separate subdirectory
mounts. cores/shaders/overlays already lived as the only subdirectories
under {games}/retroarch/ on the host, so this preserves the exact same
container-side paths — but now the parent is a real bind mount with no
auto-created stub in the way, and RetroArch's other generated config
(button remaps, core options, playlists, cheats, etc.) persists across
sessions too, which the old three-mount setup silently discarded.
2026-08-31 20:09:33 +00:00
Claude 3ffcde7294 Fix gitea-github-sync.sh: stop mirroring refs/pull/* into Gitea/GitHub
Both sync directions used `git clone --bare` for the first clone and
`git push --mirror` for the push. A bare clone pulls in every ref the
remote advertises, refs/pull/*/head included — GitHub (and Gitea,
same behavior) exposes PR refs over the same smart-HTTP endpoint a
plain bare clone reads from. --mirror then pushes every local ref
verbatim, including those, and gets rejected: Gitea's server-side
hook (and GitHub's own PR-ref protection) reserves that namespace for
itself.

  remote: error: hook declined to update refs/pull/1/head
  ! [remote rejected] refs/pull/1/head (hook declined)

Fix: scope the initial clone (now git init --bare + fetch, unified
with the repeat-sync path instead of a separate git-clone branch) and
the push to an explicit refs/heads/*:refs/heads/* + refs/tags/*:refs/tags/*
refspec in both directions, matching the refspec discipline the fetch
side already had. Added a defensive cleanup (delete any
refs/pull/*, refs/merge-requests/*, refs/changes/* found in the local
bare mirror before pushing) so a repo synced before this fix
self-heals on its next run instead of tripping the same hook forever.
2026-08-31 18:51:53 +00:00
Outis a33fb0fd84 Merge pull request #404 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Add samba service: shares, dedicated users, LAN-scoped firewall
2026-08-31 14:45:39 -04:00
Claude f8bfce87d9 Add samba service: shares, dedicated users, LAN-scoped firewall
New services/samba.sh, following the non-Docker service shape
(services/crowdsec.sh) since Samba runs natively (smbd/nmbd), not in
a container:

- Installs the samba package if missing
- Prompts to add one or more shares (path, guest vs. authenticated)
- For authenticated shares, creates a system Linux account (if one
  doesn't already exist) and a separate Samba password via smbpasswd
  for each user, adds them to a sambashare group
- Appends share stanzas to /etc/samba/smb.conf (tagged with a
  # ubuntu-post-install:share:<name> marker for later discovery),
  validates with testparm before restarting smbd/nmbd
- Opens UFW for SMB (137/138 udp, 139/445 tcp), scoped to the
  detected LAN subnet by default rather than the whole internet
- Writes a docs-only README under ~/docker/samba (no compose stack)

Registered under `utilities`, with an is_installed()/install_count()
entry in setup.sh (command -v smbd, matching the glow/crowdsec
pattern for non-Docker services) and a README.md Services table entry.

Also wired as an optional nudge into services/base.sh, alongside the
existing Caddy/CrowdSec/NetBird prompts — offered during the base
install but not unconditional, since (unlike net-tools/ncdu) it needs
real input — a share path and at least one user — to do anything
useful, so it defaults to declined rather than accepted.
2026-08-31 18:39:11 +00:00
Outis c584bc45cd Merge pull request #403 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-08-31 14:19:09 -04:00
Claude 591bdd0e79 Fix gitea.sh: open the SSH clone port in UFW
install_gitea() scanned WEB_PORT/SSH_PORT and published both in
docker-compose.yml but never opened either in UFW. With UFW active,
a `git clone ssh://...` against the SSH port silently drops instead
of getting connection-refused, which just hangs forever with no
error — the exact symptom reported.

The web port can be safely left off the public rule when Caddy fronts
it locally (scoped to caddy_net instead, matching every other service
here), but SSH can't be proxied through Caddy at all, so it always
gets a direct ufw allow now.
2026-08-31 18:01:43 +00:00
Claude 8a298d161a Fix wolf-pair: scan for a free port instead of hardcoding 8090
wolf-pair runs network_mode: host, so a taken 8090 fails at container
start with "address already in use" and no ports: line in
docker-compose.yml to explain why — wordpress, ntfy, and beszel all
default to 8090 too and correctly scan for a free port; wolf-pair
hardcoded it in three places (installer var, UFW rule, server.py's
bind) with no scan at all.

Now finds a free port via find_free_port, persists it in a new .env
(read back on rerun so a live install never silently moves), and
threads it into the container via WOLFPAIR_PORT so server.py binds
the scanned port instead of a literal 8090.
2026-08-31 17:44:22 +00:00
Outis 63eab19e9c Merge pull request #402 from outis1one/claude/pressbooks-authelia-setup-tcryml
Fix pressbooks.sh: move proxy/Prince/DocRaptor config out of wp-confi…
2026-08-31 09:10:34 -04:00
Claude 5e281e3d11 Fix pressbooks.sh: move proxy/Prince/DocRaptor config out of wp-config.php
WP-CLI's Runner does its own restricted, line-level parsing of
wp-config.php to pull bootstrap constants without a full WordPress load,
and it only tolerates plain define(...) statements — the previous fix's
"if (file_exists(...)) { require ...; }" line (routed in via
WORDPRESS_CONFIG_EXTRA) made every wp-cli command fail with a cryptic
"PHP Parse error ... eval()'d code ... unexpected end of file",
regardless of whether the required file actually existed.

Moved the X-Forwarded-Proto shim and the PB_PRINCE_COMMAND/
DOCRAPTOR_API_KEY defines into a WordPress must-use plugin
(wp-content/mu-plugins/), which loads through WordPress's normal plugin
bootstrap rather than wp-cli's special wp-config.php pre-parser — this
sidesteps both that bug and the earlier Compose .env-interpolation bug
in the same stroke, since nothing here touches wp-config.php or .env at
all anymore. Dropped the now-unnecessary extra-config.php bind mount
from the compose file and every wp-cli invocation to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-31 02:40:23 +00:00
Outis d0953890e6 Merge pull request #401 from outis1one/claude/pressbooks-authelia-setup-tcryml
Fix pressbooks.sh: wp-cli commands failing with "core: not found"
2026-08-30 22:32:30 -04:00
Claude a4d33f6afd Fix pressbooks.sh: wp-cli commands failing with "core: not found"
wp-config.php's WORDPRESS_CONFIG_EXTRA requires extra-config.php, but the
ephemeral "docker run wordpress:cli ..." containers used for every wp-cli
call only mounted html/, not that file — loading wp-config.php there hit
a PHP fatal, which broke the wordpress:cli entrypoint's own internal
"wp help $1" probe for whether to prepend "wp". That probe failing
silently falls through to exec-ing the raw subcommand as a literal binary
("core: not found") instead of running it through wp-cli at all.

Fixed by: bind-mounting extra-config.php into every wp-cli invocation too,
guarding the require with file_exists so a missing mount can't fatal
wp-config.php again, and spelling "wp" out explicitly in the wpcli
wrapper functions rather than depending on the entrypoint's own
bootstrap-dependent auto-detection. Updated the manual-retry command
printed on failure and the README's wp-cli example to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-31 02:27:30 +00:00
Outis 0f0740a322 Merge pull request #400 from outis1one/claude/pressbooks-authelia-setup-tcryml
Fix pressbooks.sh: docker compose up never ran after a successful build
2026-08-30 22:20:56 -04:00
Claude 28d6d8faf4 Fix pressbooks.sh: docker compose up never ran after a successful build
"if ! docker compose build && docker compose up -d" only negates the
build command's exit status, so on a normal successful build the whole
&&-chain short-circuited false and docker compose up -d never executed —
no containers started, no pressbooks_net network created, so every
following wp-cli call ("docker run --network pressbooks_net ...") failed
with "network pressbooks_net not found". Split into two separate checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-31 02:19:23 +00:00
Outis 42072387f8 Merge pull request #399 from outis1one/claude/pressbooks-authelia-setup-tcryml
Add Pressbooks: self-hosted book platform on WordPress Multisite
2026-08-30 19:01:08 -04:00
Outis 165f3d3ecd Merge pull request #398 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-30 19:00:40 -04:00
Claude 697ee95461 Add Pressbooks: self-hosted book platform on WordPress Multisite
Dedicated WordPress Multisite install (never shared with wordpress.sh,
since Pressbooks requires a fresh network) with a custom image adding
mod_rewrite/AllowOverride, Ghostscript/ImageMagick/poppler-utils for the
cover generator, and an optional PrinceXML install for PDF export
(DocRaptor offered as a SaaS alternative). Chapters use WordPress's own
block editor, which already supports drag-and-drop image placement.
Gated by Authelia SSO via the standard forward_auth pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1Xynq3mBwtH45f8bTDfta
2026-08-30 21:38:32 +00:00
Claude 07a6786ea5 Document manual/API fallback for Homebox's missing entity types (#1593)
services/homebox.md gets auto-appended to Homebox's generated README
by write_readme(). Covers the installer's own opt-in fix plus a fully
manual UI walkthrough and a direct curl/API path, for anyone who'd
rather not paste a token into the installer or who's confirmed a
multi-collection setup isn't worth chasing through it.

UI navigation (collection selector -> Collection options -> Entity
Types tab, /collection/entity-types) confirmed against Homebox's own
frontend source rather than guessed.
2026-08-28 11:10:07 +00:00
Claude fe9ff46081 Add opt-in fix for Homebox's missing default entity types (#1593)
Some Homebox collections never get their default Location/Item entity
types seeded (a known upstream bug), leaving the Create dialog's type
dropdown empty and every creation attempt failing with "Please select
an entity type".

_homebox_offer_entity_type_fix() repairs this without ever storing a
credential: entity types are scoped per collection with no
unauthenticated API access, so it prompts for a pasted API token at
the moment it runs (used once, never written to .env or disk, same
model as Immich's own admin-API-key prompt), then seeds the two
default types only if none already exist. Wired into both the
fresh-install and update paths.
2026-08-27 13:19:09 +00:00
Outis b427127200 Merge pull request #397 from outis1one/claude/frigate-authelia-openid-0l1htj
Fix Homebox OIDC invalid_scope error by allowing per-client extra scopes
2026-08-27 08:46:50 -04:00
Outis 09f46f96c1 Merge pull request #396 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-27 08:17:01 -04:00
Outis dad93dd646 Merge pull request #395 from outis1one/claude/mattermost-android-notifications-d02w94
Claude/mattermost android notifications d02w94
2026-08-27 08:16:34 -04:00
Claude 31ba6678d7 Add FQDN-change-specific causes to the push-notification troubleshooting doc
DNS propagation lag, TLS cert readiness, and a stale SiteURL all follow
directly from a migration that also changes domains, on top of the
DB-import device-registration cause already documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AddPmva5bfrUW3MoPriq21
2026-08-26 00:08:18 +00:00
Claude fce2e7caf1 Document Android push-notification troubleshooting for Mattermost
Covers the migrated-from-PikaPods case: stale device registrations
carried over by the DB import, server-to-push-proxy connectivity,
OEM battery optimization, and the push-content setting — the likely
causes when only some Android users stop getting background pushes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AddPmva5bfrUW3MoPriq21
2026-08-25 22:56:58 +00:00
Outis 74b5a0dc7a Merge pull request #394 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-25 13:27:36 -04:00
Outis 294e935ffd Merge pull request #393 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-25 06:55:30 -04:00
Outis 8e7ffc5185 Merge pull request #392 from outis1one/claude/frigate-authelia-openid-0l1htj
Correct stale Mealie vision-import guidance in ai-stack.md
2026-08-24 12:02:25 -04:00
Outis 2d2e6aae88 Merge pull request #391 from outis1one/claude/frigate-authelia-openid-0l1htj
Add group/site summary view; let Mealie fully hand off login to Authelia
2026-08-24 11:30:17 -04:00
Outis 5747eebf86 Merge pull request #390 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-24 08:36:08 -04:00
Outis ee7d40b0ce Merge pull request #389 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-23 21:42:17 -04:00
Outis 6995afdc66 Merge pull request #388 from outis1one/claude/frigate-authelia-openid-0l1htj
Make every authelia.sh menu numbered with 0 = exit, and fix leftover …
2026-08-23 20:08:36 -04:00
Outis 65a8de0ab9 Merge pull request #387 from outis1one/claude/frigate-authelia-openid-0l1htj
Self-install a PATH wrapper so setup.sh works from any directory
2026-08-23 19:50:28 -04:00
Outis ad4758e331 Merge pull request #386 from outis1one/claude/frigate-authelia-openid-0l1htj
Let OIDC app registration pick a domain from Caddy too, not just type it
2026-08-23 19:36:06 -04:00
Outis 23592a43bf Merge pull request #385 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-23 19:30:26 -04:00
Outis 845e06e079 Merge pull request #384 from outis1one/claude/frigate-authelia-openid-0l1htj
Make the Authelia login portal's subdomain configurable, not hardcoded
2026-08-22 13:06:00 -04:00
Outis d3553ee168 Merge pull request #383 from outis1one/claude/frigate-authelia-openid-0l1htj
Serve a keep-alive page instead of Asterisk's own web admin publicly
2026-08-22 12:42:21 -04:00
Outis 758adf744e Merge pull request #382 from outis1one/claude/frigate-authelia-openid-0l1htj
Add "remove a protected domain" to Authelia's existing-install menu
2026-08-22 00:47:22 -04:00
Outis 560c33f737 Merge pull request #381 from outis1one/claude/nvidia-gpu-flux-2-c7y9fg
docs(ai-stack): give exact claude mcp add commands for gitea-mcp
2026-08-22 00:46:53 -04:00
Claude b0c9d73030 feat(claude-cli): add dual-account setup with model/effort defaults
New extras service: installs the Claude Code CLI, creates separate
work/personal CLAUDE_CONFIG_DIR accounts behind claude-work/claude-personal
aliases, and applies settings.json defaults to both (model=claude-sonnet-5,
effortLevel=medium, ENABLE_PROMPT_CACHING_1H=1). A shared ~/.claude-shared/
CLAUDE.md holds the durable personal conventions (modular/reuse code,
numbered CLI menus with 0=exit, Playwright verification for UI changes)
and both accounts pull it in via @import so there's one copy to maintain.

Login itself (browser OAuth) can't be scripted - this only prepares the
directories/aliases/config; each account still needs one interactive
login. Tested fresh-install, rerun/cancel detection, and the jq settings
merge (preserves hand-added keys) in a scratch HOME.
2026-08-22 04:46:43 +00:00
Claude b7411b236e docs(ai-stack): give exact claude mcp add commands for gitea-mcp
Replace the vague "add it with claude mcp add" with real commands for
both stdio (single local CLI) and HTTP (shared server) transport,
verified against gitea/gitea-mcp's actual distribution (binary/Docker/
go run, -t stdio|http flags, GITEA_HOST/GITEA_ACCESS_TOKEN env vars).
2026-08-22 04:29:14 +00:00
Outis f52713b67d Merge pull request #380 from outis1one/claude/nvidia-gpu-flux-2-c7y9fg
Claude/nvidia gpu flux 2 c7y9fg
2026-08-22 00:25:15 -04:00
Outis 3614342807 Merge pull request #379 from outis1one/claude/frigate-authelia-openid-0l1htj
Let Security Dashboard take over Asterisk's public domain
2026-08-22 00:24:47 -04:00
Claude cfee4b292b docs(ai-stack): add local-GPU + Claude Code hybrid workflow section
Covers the CLAUDE.md tiering (user/project/local/one-off task) for
splitting instructions between the local coder model and Claude Code,
plus how Claude Code reads from self-hosted Gitea (plain git vs. the
official gitea/gitea-mcp server for PR/issue-level integration).
2026-08-22 04:22:51 +00:00
Claude c703b1c4b4 docs(ai-stack): disambiguate Tesla brand name from Tesla microarchitecture
"Tesla" was NVIDIA's datacenter-card brand (retired after Volta), spanning
four unrelated architecture generations (Kepler/Maxwell/Pascal/Volta) - a
different, much older thing than the Tesla microarchitecture itself, which
actually predates Fermi/Kepler/Maxwell/Pascal/Volta. Clarify to avoid
reading the power-connector note as claiming Tesla == Volta.
2026-08-22 03:55:27 +00:00
Claude 7f23860078 docs(ai-stack): name the R580 driver branch for Volta/Pascal cards
Confirmed via NVIDIA's own release notes: R580 is the last branch
adding Volta/Pascal support (through ~June 2028). Also flag the
proprietary-vs-open kernel module split, since -open only covers
Turing and newer and autoinstall could pick it on a newer distro.
2026-08-22 03:52:39 +00:00
Claude 503945e7c3 docs(ai-stack): correct CUDA deprecation status, add Tesla power/cooling note
CUDA 13 already dropped Pascal/Volta (not a future warning anymore) -
call out pinning the driver branch instead. Also flag the 8-pin
CPU/EPS12V power connector and passive cooling on Tesla PCIe cards,
since it trips up anyone repurposing one in a tower case.
2026-08-22 03:46:06 +00:00
Claude f5a23b06ac docs(ai-stack): note MoE models as a coding option for Pascal/Volta
MoE VRAM tracks total params but compute tracks active params, so a
model like qwen3-coder:30b-a3b (30B total, ~3.3B active) runs light
enough that old GPUs' weak/missing tensor cores barely matter.
2026-08-22 03:41:25 +00:00
Outis a595e45294 Merge pull request #378 from outis1one/claude/nvidia-gpu-flux-2-c7y9fg
docs(ai-stack): add server GPU generation capability table
2026-08-21 23:36:00 -04:00
Outis 2b69cfac1d Merge pull request #377 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-21 23:22:07 -04:00
Claude e54c7827de docs(ai-stack): add server GPU generation capability table
Reference table for Blackwell/Hopper/Ampere/Volta/Pascal/Maxwell covering
Flux 2, Flux.1/SDXL, chat, and coding model capability per generation.
2026-08-22 03:22:02 +00:00
Outis 953744b64c Merge pull request #376 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-21 21:02:38 -04:00
Outis 85f3a89ef5 Merge pull request #375 from outis1one/claude/frigate-authelia-openid-0l1htj
Claude/frigate authelia openid 0l1htj
2026-08-21 17:55:27 -04:00
22 changed files with 8047 additions and 150 deletions
+21 -2
View File
@@ -493,7 +493,7 @@ right (Portainer, ntfy), not general familiarity with the product:
| Service | Native OIDC? | Notes |
|---|---|---|
| `mealie` | Yes — wired up | Pure env vars (`OIDC_AUTH_ENABLED`, `OIDC_CLIENT_ID/SECRET`, `OIDC_CONFIGURATION_URL`), see `_mealie_offer_authelia_oidc()`. Redirect URI is `<BASE_URL>/login`. Needs a `--forwarded-allow-ips` entrypoint override when Caddy-fronted, or the generated redirect URI comes out `http://` even when actually served over `https://` — see the function's own comment. |
| `homebox` | Yes — wired up | Pure env vars (`HBOX_OIDC_ENABLED`, `HBOX_OIDC_ISSUER_URL`, `HBOX_OIDC_CLIENT_ID/SECRET`, `HBOX_OIDC_SCOPE`), see `_homebox_offer_authelia_oidc()`. Confirmed against homebox.software's own OIDC docs and authelia.com's Homebox integration page — needs PKCE (unlike Mealie/ActualBudget). Redirect path is `/api/v1/users/login/oidc/callback`; issuer URL is reportedly sensitive to a trailing slash (a real upstream bug), so it's written from this repo's own portal-URL value as-is, never with one appended. The stock compose template didn't have `env_file: .env` (vars were listed individually in `environment:` instead) — added to the template, and patched onto any pre-existing install's compose file the first time this offer runs, or the written `.env` additions would silently never reach the container. `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false`/`HBOX_OIDC_AUTO_REDIRECT=true` are real, documented env vars for fully replacing local login, offered as a separate step gated behind the same "have you tested the button first" confirmation as Mealie/Beszel. Unlike every other native-OIDC integration in this table, `HBOX_OIDC_SCOPE` needs a fourth scope, `groups`, alongside the usual `openid profile email` — Authelia's own Homebox integration page documents this. Confirmed live: requesting it without also granting it broke login outright (`invalid_scope: "The OAuth 2.0 Client is not allowed to request scope 'groups'"`), because Authelia enforces a per-client scopes allowlist independent of what the server supports overall — `_authelia_provision_oidc_client()` used to hardcode `openid`/`profile`/`email` for every caller with no way to add more. Fixed by giving it a 6th positional arg, `EXTRA_SCOPES` (space-separated, inserted right after `REQUIRE_PKCE`), that every other existing caller passes as `""` — Homebox's is the only caller that passes `"groups"`. |
| `homebox` | Yes — wired up | Pure env vars (`HBOX_OIDC_ENABLED`, `HBOX_OIDC_ISSUER_URL`, `HBOX_OIDC_CLIENT_ID/SECRET`, `HBOX_OIDC_SCOPE`), see `_homebox_offer_authelia_oidc()`. Confirmed against homebox.software's own OIDC docs and authelia.com's Homebox integration page — needs PKCE (unlike Mealie/ActualBudget). Redirect path is `/api/v1/users/login/oidc/callback`; issuer URL is reportedly sensitive to a trailing slash (a real upstream bug), so it's written from this repo's own portal-URL value as-is, never with one appended. The stock compose template didn't have `env_file: .env` (vars were listed individually in `environment:` instead) — added to the template, and patched onto any pre-existing install's compose file the first time this offer runs, or the written `.env` additions would silently never reach the container. `HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false`/`HBOX_OIDC_AUTO_REDIRECT=true` are real, documented env vars for fully replacing local login, offered as a separate step gated behind the same "have you tested the button first" confirmation as Mealie/Beszel. Unlike every other native-OIDC integration in this table, `HBOX_OIDC_SCOPE` needs a fourth scope, `groups`, alongside the usual `openid profile email` — Authelia's own Homebox integration page documents this. Confirmed live: requesting it without also granting it broke login outright (`invalid_scope: "The OAuth 2.0 Client is not allowed to request scope 'groups'"`), because Authelia enforces a per-client scopes allowlist independent of what the server supports overall — `_authelia_provision_oidc_client()` used to hardcode `openid`/`profile`/`email` for every caller with no way to add more. Fixed by giving it a 6th positional arg, `EXTRA_SCOPES` (space-separated, inserted right after `REQUIRE_PKCE`), that every other existing caller passes as `""` — Homebox's is the only caller that passes `"groups"`. Separately, some Homebox collections hit an unrelated upstream bug (sysadminsmedia/homebox#1593): the default `Location`/`Item` entity types never get seeded for that collection, so the Create dialog's type dropdown comes up empty and creation fails with "Please select an entity type" regardless of Authelia. `_homebox_offer_entity_type_fix()` is the opt-in repair: entity types are scoped per collection with no unauthenticated read/write (confirmed against Homebox's own swagger doc — `GET`/`POST /v1/entity-types` both require a bearer token, and there's no documented endpoint to switch a token between a user's collections), so rather than baking in or storing any credential it prompts for a pasted API token at the moment it runs — same one-time, never-persisted trust model as `_immich_offer_authelia_oidc()`'s own admin-API-key prompt — checks for an existing `isLocation:true` type, and POSTs the two defaults only if none exist. Explicitly told upfront that fixing it only covers the one collection that token's account belongs to; a multi-collection user has to repeat the step once per collection. |
| `actualbudget` | Yes — wired up | Pure env vars (`ACTUAL_OPENID_DISCOVERY_URL`, `ACTUAL_OPENID_CLIENT_ID/SECRET`, `ACTUAL_OPENID_SERVER_HOSTNAME`), see `_actualbudget_offer_authelia_oidc()`. Redirect path `/openid/callback` (matches the existing preset in `_authelia_add_oidc_client()`'s menu). First OIDC login becomes the server owner if none is set yet — Actual's own behavior. |
| `immich` | Yes — wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by `GET`/`PUT /api/system-config` — confirmed the exact JSON field names against Immich's own `config-file.md` and source directly (the `oauth` sub-object: `enabled`/`issuerUrl`/`clientId`/`clientSecret`/`scope`/`buttonText`, etc.), not guessed. See `_immich_offer_authelia_oidc()`. GET/PUT exchange the *whole* config object (no partial-patch endpoint), so it round-trips everything else — storage template, library settings — completely unchanged; the same shape already proven by `import-photos.sh`'s own storage-template step in this file. Needs an admin API key, which doesn't exist until the user creates their account on first web visit — this offer runs from both the fresh-install path (usually a no-op that first time) and the "update" rerun path, which is the realistic way most people finish this. |
| `audiobookshelf` | Yes — wired up (Authelia side only) | Checked against audiobookshelf.org's own OIDC docs: config is UI-only (Settings → Authentication), no env var or config API — so `_audiobookshelf_offer_authelia_oidc()` registers the Authelia client (needs PKCE, confirmed via authelia.com's own integration page for it) and prints the exact individual-endpoint values to paste in, since Audiobookshelf wants those rather than a discovery URL. Three redirect URIs: web callback, mobile-redirect, and the `audiobookshelf://oauth` app-scheme callback. |
@@ -623,6 +623,25 @@ in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
Sessions persist through reboots regardless of duration (Redis stores
session state in a volume).
**`inactivity` must track `remember_me`, or a long remember_me is a lie.**
`inactivity` is a separate session field — how long a session can sit idle
before Authelia ends it — and it is NOT extended or bypassed by the
"Remember me" checkbox; the two are independent. Confirmed live: a user
set `remember_me: 1y` expecting "won't be asked to log in again for a
year," but the install default left `inactivity` at a much shorter value
(2h at the time), so ordinary daily gaps between visits (overnight, a
workday) ended the session on inactivity grounds well before remember_me
ever came into play — the 1y setting was doing nothing. Fixed at both ends
so this can't recur silently: `install_authelia()`'s own template now sets
`inactivity: 7d`, matching its `remember_me: 7d` default instead of a
shorter one, and `_authelia_set_remember_me()` now writes the SAME new
duration into both keys on every change, not just `remember_me` alone. If
you ever hand-edit `session:` instead of using the menu option, keep
`inactivity` and `remember_me` equal — a mismatch here is exactly the bug
above, not a valid intentional configuration. `expiration` (the cap for a
session that never checked "Remember me") is a legitimately different,
shorter-by-design setting and is untouched by any of this.
**The config key is `remember_me`, not `remember_me_duration`.** Authelia
renamed it in 4.38; this repo pins `4.39.20`. A stale `remember_me_duration`
key doesn't error, Authelia just silently ignores it — confirmed against
@@ -635,7 +654,7 @@ touch this by hand instead of the menu option, the current schema is:
session:
secret: 'your-existing-secret'
expiration: 1h
inactivity: 5m
inactivity: 1y
remember_me: 1y
cookies:
- domain: 'example.com'
+26 -2
View File
@@ -186,11 +186,11 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `anki-progress` (read-only study-progress dashboard for an `anki-sync-server` instance — reviews/accuracy/streak per account, plus an ntfy notification once a study session has been going for a configurable number of minutes; reads collection files with SQLite's read-only mode so it can't interfere with the live sync server), `anki-sync-server` (self-hosted sync backend for the Anki flashcard app — spaced-repetition scheduling stays in the Anki client, this just syncs collections across devices without AnkiWeb; supports multiple independent accounts per instance), `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `gitea` (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the `ai-stack` bundle's own Gitea container), `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `pressbooks` (self-hosted book platform — WordPress Multisite, drag-and-drop chapter editing, PDF export via PrinceXML/DocRaptor, Authelia-gated), `rustdesk`, `samba` (SMB/CIFS file sharing — shares, dedicated Samba users/passwords, LAN-scoped firewall by default; also offered as an optional nudge from `base`), `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc` |
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc`, `claude-cli` (Claude Code CLI — dual-account work/personal setup, model/effort defaults, shared global CLAUDE.md) |
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
Run `./setup.sh --list` to see descriptions.
@@ -240,6 +240,7 @@ utilities
onlyoffice
paintplus
portainer
pressbooks
rustdesk
stirling-pdf
syncthing
@@ -282,6 +283,7 @@ extras
ssh-config
ssh-key-import
sync-cc
claude-cli
backup
backup
@@ -291,6 +293,28 @@ backup
</details>
## Generating Anki decks (tools/anki-deck-*.py)
`anki-sync-server` gives you a self-hosted sync backend, but a fresh
account has no content — `tools/anki-deck-math.py`,
`tools/anki-deck-periodic.py`, and `tools/anki-deck-visual.py` generate
ready-to-import `.apkg` decks (multiplication/division/addition/
subtraction/fractions/decimals, the periodic table, and shapes/clocks/
coin-counting) with Anki's built-in type-the-answer input and offline
neural TTS audio (Piper) on every card. All three are standalone Python
scripts, unrelated to the `services/*.sh` installer framework — run them
on any machine with Python, not necessarily the server itself. Full setup
(a venv, `genanki` + `piper-tts`, downloading a voice) and every deck's
exact usage is documented in `tools/anki-deck-math.py`'s own header
docstring; the other two scripts point back to it rather than repeating
the same instructions three times.
Shapes, clocks, and coin images are drawn programmatically (SVG) rather
than AI-generated — image generation is a poor fit for content that has
to be exactly correct (an exact clock time, an exact side count), not
just plausible-looking; see `tools/anki-deck-visual.py`'s own docstring
for more on that tradeoff.
## Layout
```
+124
View File
@@ -10,6 +10,62 @@
less text (saves tokens), for both local and cloud models.
- Web search uses **DuckDuckGo** (no SearXNG in this build).
## Hybrid workflow — local coding model + Claude Code
Split coding work by size, not by tool preference. This stack's local Ollama
coder model (the GPU generations table below has sizing per card) handles
fast, in-loop iteration — autocomplete, boilerplate, single-file refactors,
private/offline drafting, zero token cost. Claude Code (cloud) handles the
bigger, longer, cross-file work — architectural refactors, anything needing
full-repo context or stronger judgment — driven against this stack's Gitea
(or GitHub, via the `gitea-github-sync.sh` mirror in Roles above).
### Where to put instructions for each side
Claude Code loads `CLAUDE.md` in four tiers, concatenated broadest to most
specific — later tiers add to earlier ones, they don't replace them:
| Tier | Path | Put here |
|---|---|---|
| User | `~/.claude/CLAUDE.md` | Your personal conventions, true on *every* project — e.g. "CLI menus are numbered, `0` is always exit," "verify UI changes with Playwright," your code-style rules |
| Project | `./CLAUDE.md` or `./.claude/CLAUDE.md` | This codebase's own architecture/conventions, shared with collaborators via git (this file is the reference example) |
| Local | `./CLAUDE.local.md` (gitignored) | Your personal per-project notes — sandbox URLs, test data |
| One-off task | The prompt itself, handed over when you say "go" | The specific feature/idea for *this* build — never durable, don't put it in `CLAUDE.md` |
Write cross-project quirks into `~/.claude/CLAUDE.md` once — every project
inherits them automatically, no per-repo duplication needed. If it grows
past ~200 lines, split it into `~/.claude/rules/*.md` (still user-level,
loads before project-level rules).
### Claude Code reading from self-hosted Gitea
Two levels, depending on what you need:
- **Plain git — works today, nothing to install.** Claude Code's git
operations are shell `git` commands, not a GitHub-specific code path —
clone/push/pull against this stack's Gitea over SSH or an HTTPS token
exactly like any other remote. This only applies to a locally-run Claude
Code CLI against your own machine; a cloud/remote Claude Code session
(like the one used to write this doc) is scoped to whichever provider —
typically GitHub — it was attached to at session start, and can't reach
an arbitrary self-hosted Gitea on your LAN.
- **PR/issue/CI-level integration (optional).** Reading/commenting on Gitea
PRs and issues the way a GitHub MCP server does for GitHub needs an MCP
server that speaks Gitea's REST API. Gitea's own project publishes one —
`gitea/gitea-mcp` (gitea.com/gitea/gitea-mcp) — as a binary release, a
Docker image (`docker.gitea.com/gitea-mcp-server`), or `go run
gitea.com/gitea/gitea-mcp@latest`; it supports both stdio and HTTP
transport. Generate a token first — this stack's Gitea → profile →
Settings → Applications → Generate New Token (repo/api scopes) — then:
```bash
# stdio — simplest, one Claude Code CLI on this box
claude mcp add gitea --env GITEA_HOST=http://localhost:3001 \
--env GITEA_ACCESS_TOKEN=<token> -- gitea-mcp -t stdio
# or HTTP — one server, shared by multiple Claude Code clients
gitea-mcp -t http --port 8090 & # run once, e.g. alongside the stack
claude mcp add gitea http://localhost:8090/mcp \
--header "Authorization: Bearer <token>"
```
Not bundled by default — this stack's Gitea has no built-in Claude
integration out of the box; this is you adding it.
## GPU switcher (small local GPU only)
One small GPU can't run local chat and local image-gen at once. Swap it:
```bash
@@ -78,6 +134,74 @@ turns on the photo-import feature. No Mealie container restart needed, it
applies live. See Open WebUI → Settings → Connections if you'd rather
confirm the local base URL/model name there first.
## NVIDIA server-GPU generations — capability reference
What a given datacenter GPU generation can actually run through this stack
(Ollama for chat/code, ComfyUI/InvokeAI for images), since it's VRAM- and
tensor-core-bound per generation. Only Ampere and newer have native BF16
tensor cores; llama.cpp/Ollama's CUDA backend supports Pascal (compute
capability 6.0) and up, so quantized chat/coding model size mostly comes
down to VRAM capacity — older cards just run slower per token, with no
flash-attention-class kernel path.
| Generation | Example server cards | VRAM | Flux 2 (32B DiT) | Flux.1 / SDXL | Chat (GGUF, Ollama) | Coding (GGUF, Ollama) |
|---|---|---|---|---|---|---|
| Blackwell (2024-25) | B100 / B200 / GB200 | 180-192GB HBM3e | Yes — FP8 fast, native | Yes, fast | 70B+ at high precision, easily | Any coder model, full precision |
| Hopper (2022) | H100 / H200 | 80-141GB HBM3 | Yes — FP8 native tensor cores; the target generation | Yes, fast | 70B in Q4-Q8 comfortably | Qwen2.5-Coder-32B / DeepSeek-Coder-V2, full precision |
| Ampere (2020) | A100 40/80GB | 40-80GB HBM2e | Minimum viable — FP8 checkpoint (~32GB) fits the 80GB card; no native FP8 tensor cores, so it's upcast/emulated rather than accelerated | Yes, comfortable (native BF16/TF32) | 70B Q4 (~40GB) fits the 80GB card with room; 30-34B comfortable on the 40GB card | Qwen2.5-Coder-32B / Codestral-22B comfortable |
| Volta (2017) | V100 16/32GB | 16-32GB HBM2 | No — even the 32GB card has no headroom for the FP8 checkpoint plus activations | FLUX.1-dev FP8 (~18-23GB) fits the 32GB card, tight; SDXL/SD1.5 fine (first-gen FP16 tensor cores) | 32GB card: 30-34B Q4 comfortable, 70B tight/needs multi-GPU. 16GB card: 13-14B comfortable | 32B coder models fit the 32GB card in Q4 |
| Pascal (2016) | P100 16GB / P40 24GB | 16-24GB HBM2/GDDR5 | No | SD1.5 fine; SDXL runs but slow — no tensor cores at all, weak/emulated FP16 (worse on the P40 than the P100) | Same VRAM math as Ampere/Volta at matched capacity (P40 24GB ≈ 30B Q4), but noticeably slower tokens/sec | 32B coder Q4 fits the P40 24GB capacity-wise; fine for batch/background, not snappy interactive autocomplete |
| Maxwell (2014) | M40 / M60 24GB | 8-24GB GDDR5 | No | Impractical — SD1.5 only, very slow; no real FP16 tensor path | 7B-13B Q4 runs but slow | 7B-class coder models only — a novelty, not a daily driver |
**CUDA 13 has already dropped Pascal/Volta** (this happened, it's not a future
warning anymore) — but that's the *toolkit*, not the driver, and it doesn't
block this stack: Docker GPU passthrough only needs the host *driver* to
recognize the card, since prebuilt inference images (Ollama, ComfyUI, etc.)
already bundle whatever CUDA runtime they need internally. The driver is the
part to get right. **NVIDIA has named R580 the last driver branch that adds
Volta/Pascal support** (P100/P40/V100 explicitly listed), supported into
~June 2028 — pin to R580 explicitly rather than trusting `ubuntu-drivers
autoinstall`'s default pick on a fresh/newer Ubuntu install, since a later
branch may no longer initialize these cards at all. Also confirm you land on
the **proprietary** driver package, not an `-open` one — NVIDIA's open-source
kernel modules only support Turing and newer, so Volta/Pascal *require* the
closed-source module; `ubuntu-drivers devices` should recommend the right one
for the card it detects, but double-check rather than assume on a distro
release that defaults newer GPUs to `-open`. None of this is something
`require_docker` handles — it installs Docker/Compose only; the NVIDIA
driver and `nvidia-container-toolkit` are still on you to install first,
and getting the driver branch right is what actually matters here, not the
Ubuntu version itself.
**"Tesla"-branded card power connector — don't assume standard PCIe.**
("Tesla" here is NVIDIA's old datacenter-card *brand name*, retired after
Volta — not the unrelated, much older Tesla *microarchitecture* that
predates Fermi/Kepler/Maxwell/Pascal/Volta. V100/P100/P40/M40 all shipped
under the Tesla brand despite being four different architecture
generations.) These PCIe cards take an 8-pin **CPU/EPS12V** connector, not
the 6+2-pin PCIe
connector a normal GPU uses — a standard PCIe cable will not plug in. Get the
dongle/adapter (splits a PCIe 8-pin into EPS12V, or use a real EPS cable) and
never daisy-chain both 8-pin rails off one PSU cable/splitter — use two
separate cable runs. These cards are also passively cooled (built for server
chassis airflow, no onboard fan) — a tower case needs a shroud + dedicated
fan blowing through the heatsink fins, and there's no display output, which
is a non-issue on a headless box like this but worth knowing going in.
**MoE models are the exception that gives Pascal/Volta real life for coding.**
The "coding" column above assumes dense models, where token speed tracks the
full parameter count — exactly where Pascal/Volta's missing or first-gen
tensor cores hurt most. A mixture-of-experts model breaks that link: VRAM is
still set by *total* params (every expert has to be resident — no memory
saving from sparsity), but compute per token is set by *active* params only.
`qwen3-coder:30b-a3b` in `ollama pull` is the concrete case — 30B total, only
~3.3B active per token (128 experts, 8 routed) — so it needs the same ~19GB
VRAM (Q4_K_M) as a dense 30B model but computes like a dense ~3B one. That's
light enough that Pascal/Volta's weak tensor cores barely matter, making it
the best coding model to put on a P40 24GB or a V100 — a dense 32B coder on
the same card would be noticeably slower for no quality gain. Mixtral 8x7B
(46.7B total / ~13B active, ~24-26GB at Q4) is the same trade at a larger
size — fits Volta 32GB or Ampere, with the same active-vs-total gap.
## Cloud LLM providers (Open WebUI)
Open WebUI uses an OpenAI-compatible connection list. The local RAG server is the
first entry; any cloud providers added at install follow it. Two semicolon-separated
+741
View File
@@ -0,0 +1,741 @@
#!/bin/bash
# services/anki-progress.sh — Anki study-progress dashboard + ntfy
# "started studying" notifications. Reads an anki-sync-server instance's
# data directly (read-only) — see services/anki-sync-server.sh, which this
# service requires.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash anki-progress.sh
# (Docker must already be installed, and an anki-sync-server instance must
# already exist on the same box, when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Already installed."
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
case "${_r,,}" in
u|update) eval "$_var='update'" ;;
f|fresh) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service anki-progress utilities "Anki study-progress dashboard + ntfy 'started studying' notifications (reads an anki-sync-server instance's data read-only)" 8099
install_anki-progress() {
require_docker || return 1
log_info "Installing Anki Progress Dashboard..."
# ── Dependency: needs an anki-sync-server instance already installed ────
# Meaningless on its own — see CLAUDE.md's "Chaining into another
# service" section. Only chains one direction: anki-progress requires
# anki-sync-server, never the reverse.
local _sync_dirs=()
local _d
for _d in "$DOCKER_DIR"/anki-sync-server*; do
[ -d "$_d" ] && _sync_dirs+=("$(basename "$_d")")
done
if [ "${#_sync_dirs[@]}" -eq 0 ]; then
log_error "No anki-sync-server install found — this dashboard reads its data directly."
log_error "Install it first: sudo ./setup.sh anki-sync-server"
return 1
fi
local SYNC_INSTANCE="${_sync_dirs[0]}"
if [ "${#_sync_dirs[@]}" -gt 1 ] && [ "$UNATTENDED" != true ]; then
echo ""
echo " Multiple anki-sync-server instances found:"
local i
for i in "${!_sync_dirs[@]}"; do
echo " $((i + 1))) ${_sync_dirs[$i]}"
done
local _choice=""
prompt_text " Which one should this dashboard monitor? [1]:" "1" _choice
if [[ "$_choice" =~ ^[0-9]+$ ]] && [ "$_choice" -ge 1 ] && [ "$_choice" -le "${#_sync_dirs[@]}" ]; then
SYNC_INSTANCE="${_sync_dirs[$((_choice - 1))]}"
fi
fi
local SYNC_DATA_DIR="$DOCKER_DIR/$SYNC_INSTANCE/data"
# ── Instance selection (of this dashboard itself) ───────────────────────
# A second instance is a real use case (e.g. a second household with its
# own anki-sync-server and its own dashboard) — same multi-instance
# pattern as every other service here (see CLAUDE.md).
local AP_DIR="$DOCKER_DIR/anki-progress"
local INSTANCE_SUFFIX="" CONTAINER="anki-progress"
local WEB_PORT="8099"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would verify an anki-sync-server instance exists ($SYNC_INSTANCE found)"
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $AP_DIR(-<name>) with app.py, Dockerfile, docker-compose.yml"
echo "[DRY-RUN] Would prompt for ntfy URL/topic and notification timing"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
if [ -d "$AP_DIR" ]; then
echo ""
echo " Anki Progress Dashboard is already installed at $AP_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate dashboard instance alongside it"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/anki-progress-$_suffix" ]; then
log_warning "anki-progress-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
AP_DIR="$DOCKER_DIR/anki-progress-$_suffix"
CONTAINER="anki-progress-$_suffix"
log_info "New instance: $AP_DIR"
else
if [[ -f "$AP_DIR/docker-compose.yml" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing app code + rebuilding the image — ntfy config and Caddy setup are left as-is."
( cd "$AP_DIR" && docker compose up -d --build ) \
&& log_success "Anki Progress Dashboard refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $AP_DIR/docker-compose.yml logs"
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;;
esac
fi
fi
fi
find_free_port WEB_PORT "$WEB_PORT"
# ── ntfy ──────────────────────────────────────────────────────────────
# If ntfy is installed locally, reach it directly over caddy_net by
# container name — avoids a round trip through the public internet for
# a purely internal notification. Otherwise ask for a full URL (a
# remote/self-hosted instance elsewhere, or public ntfy.sh).
local NTFY_URL="" NTFY_TOPIC=""
if [ -d "$DOCKER_DIR/ntfy" ]; then
log_info "Local ntfy install detected — reaching it directly over caddy_net."
NTFY_URL="http://ntfy:80"
else
prompt_text " ntfy server URL (e.g. https://ntfy.yourdomain.com, or https://ntfy.sh):" "https://ntfy.sh" NTFY_URL
fi
prompt_text " ntfy topic to publish 'started studying' notifications to:" "anki-progress" NTFY_TOPIC
local SESSION_GAP_MINUTES="" NOTIFY_DELAY_MINUTES=""
prompt_text " Minutes of inactivity that counts as a new study session starting:" "30" SESSION_GAP_MINUTES
prompt_text " Minutes after a session starts to send the notification:" "10" NOTIFY_DELAY_MINUTES
mkdir -p "$AP_DIR/state"
ensure_docker_dir_ownership "$AP_DIR"
cd "$AP_DIR" || return 1
# Mirrors configure_caddy_for_service's own mode resolution — only
# "local" joins caddy_net.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
backup_if_exists app.py
cat > app.py << 'PYEOF'
#!/usr/bin/env python3
"""Anki study-progress dashboard + ntfy "started studying" notifications.
Reads every account's collection.anki2 directly (READ-ONLY — never opens for
write, so it can't corrupt live data the sync server or a client is using)
from the anki-sync-server's data directory, and:
1. Serves a small web dashboard (reviews today/week, accuracy, streak,
last active) per account.
2. Runs a background loop that detects when a new study session starts
(first review after a gap of SESSION_GAP_MINUTES with no reviews) and
sends one ntfy notification NOTIFY_DELAY_MINUTES after that session
started, if the session is still going (i.e. more reviews happened
after the initial one) — not on every single review.
All configuration (NTFY_URL, NTFY_TOPIC, SESSION_GAP_MINUTES,
NOTIFY_DELAY_MINUTES, ANKI_DATA_DIR, STATE_FILE) comes from environment
variables, set in docker-compose.yml / .env by the installer — nothing to
hand-edit in this file.
"""
import glob
import json
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
import requests
from flask import Flask, render_template_string
NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.example.com")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "anki-progress")
SESSION_GAP_MINUTES = int(os.environ.get("SESSION_GAP_MINUTES", 30))
NOTIFY_DELAY_MINUTES = int(os.environ.get("NOTIFY_DELAY_MINUTES", 10))
POLL_INTERVAL_SECONDS = 60
ANKI_DATA_DIR = os.environ.get("ANKI_DATA_DIR", "/anki-data")
STATE_FILE = os.environ.get("STATE_FILE", "/app/state/notify_state.json")
app = Flask(__name__)
def find_collections():
"""{username: path-to-collection-file} for every account directory found.
Globs for *.anki2 rather than assuming the exact filename, since that's
an implementation detail of the sync server we shouldn't hardcode."""
result = {}
if not os.path.isdir(ANKI_DATA_DIR):
return result
for entry in sorted(os.listdir(ANKI_DATA_DIR)):
user_dir = os.path.join(ANKI_DATA_DIR, entry)
if not os.path.isdir(user_dir):
continue
matches = glob.glob(os.path.join(user_dir, "*.anki2"))
if matches:
result[entry] = matches[0]
return result
def read_revlog_ids_eases(path):
"""Returns a list of (epoch_ms, ease) tuples sorted by time, read-only.
Opening with mode=ro is what makes this safe to run alongside a live
sync server — it never takes a write lock, so it can't corrupt or
block the account that's actually in use."""
uri = f"file:{path}?mode=ro"
con = sqlite3.connect(uri, uri=True)
try:
rows = con.execute("SELECT id, ease FROM revlog ORDER BY id ASC").fetchall()
except sqlite3.OperationalError:
rows = []
finally:
con.close()
return rows
def compute_stats(revlog_rows, now_ms):
"""Pure function over a list of (epoch_ms, ease) — kept separate from
any file/DB access so it can be unit-tested with synthetic data."""
if not revlog_rows:
return {
"total_reviews": 0, "reviews_today": 0, "reviews_week": 0,
"accuracy_pct": None, "streak_days": 0, "last_active": None,
}
day_ms = 24 * 60 * 60 * 1000
today_day = now_ms // day_ms
today_start = today_day * day_ms
week_start = today_start - 6 * day_ms
reviews_today = sum(1 for ts, _ in revlog_rows if ts >= today_start)
reviews_week = sum(1 for ts, _ in revlog_rows if ts >= week_start)
total = len(revlog_rows)
correct = sum(1 for _, ease in revlog_rows if ease != 1) # ease 1 = "Again" = a miss
accuracy_pct = round(100 * correct / total, 1) if total else None
# Streak: consecutive calendar days with >=1 review, walking backward
# from today. Still "alive" through yesterday if today has no reviews
# yet (so it doesn't reset to 0 first thing each morning) — but not if
# the most recent review is 2+ days old. review_days is unique/sorted
# descending, so any day that isn't exactly "expected" means a gap.
review_days = sorted({ts // day_ms for ts, _ in revlog_rows}, reverse=True)
streak = 0
if review_days and review_days[0] in (today_day, today_day - 1):
expected = review_days[0]
for d in review_days:
if d == expected:
streak += 1
expected -= 1
else:
break
last_active = max(ts for ts, _ in revlog_rows)
return {
"total_reviews": total,
"reviews_today": reviews_today,
"reviews_week": reviews_week,
"accuracy_pct": accuracy_pct,
"streak_days": streak,
"last_active": last_active,
}
def detect_current_session_start(revlog_rows, now_ms):
"""Walk backwards from the most recent review; the session start is the
earliest review such that every gap between consecutive reviews from
there to now is < SESSION_GAP_MINUTES. Returns None if the most recent
review itself is older than the gap threshold (no session "in progress")."""
if not revlog_rows:
return None
gap_ms = SESSION_GAP_MINUTES * 60 * 1000
last_ts = revlog_rows[-1][0]
if now_ms - last_ts > gap_ms:
return None # most recent review is old news, not an active session
session_start = last_ts
for ts, _ in reversed(revlog_rows[:-1]):
if session_start - ts > gap_ms:
break
session_start = ts
return session_start
DASHBOARD_TEMPLATE = """
<!doctype html>
<title>Anki Progress</title>
<meta http-equiv="refresh" content="60">
<style>
body { font-family: Arial, sans-serif; background: #f4f6f8; margin: 0; padding: 24px; }
h1 { color: #333; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.card { background: white; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.card h2 { margin: 0 0 10px 0; font-size: 20px; }
.stat { display: flex; justify-content: space-between; margin: 4px 0; font-size: 15px; }
.stat b { color: #1c4587; }
.empty { color: #888; font-style: italic; }
</style>
<h1>Anki Progress</h1>
<div class="grid">
{% for user, s in stats.items() %}
<div class="card">
<h2>{{ user }}</h2>
{% if s.total_reviews == 0 %}
<div class="empty">No reviews yet</div>
{% else %}
<div class="stat"><span>Reviews today</span><b>{{ s.reviews_today }}</b></div>
<div class="stat"><span>Reviews this week</span><b>{{ s.reviews_week }}</b></div>
<div class="stat"><span>Accuracy</span><b>{{ s.accuracy_pct }}%</b></div>
<div class="stat"><span>Streak</span><b>{{ s.streak_days }} day{{ 's' if s.streak_days != 1 else '' }}</b></div>
<div class="stat"><span>Last active</span><b>{{ s.last_active_str }}</b></div>
{% endif %}
</div>
{% endfor %}
</div>
"""
@app.route("/")
def dashboard():
now_ms = int(time.time() * 1000)
stats = {}
for user, path in find_collections().items():
rows = read_revlog_ids_eases(path)
s = compute_stats(rows, now_ms)
if s["last_active"]:
s["last_active_str"] = datetime.fromtimestamp(
s["last_active"] / 1000, tz=timezone.utc
).astimezone().strftime("%b %-d, %-I:%M %p")
else:
s["last_active_str"] = "—"
stats[user] = s
return render_template_string(DASHBOARD_TEMPLATE, stats=stats)
def load_notify_state():
if os.path.isfile(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {}
def save_notify_state(state):
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(state, f)
def send_ntfy(message):
try:
requests.post(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}",
data=message.encode("utf-8"), timeout=10)
except requests.RequestException as e:
print(f"[ntfy] failed to send: {e}")
def notifier_loop():
state = load_notify_state()
while True:
now_ms = int(time.time() * 1000)
for user, path in find_collections().items():
rows = read_revlog_ids_eases(path)
session_start = detect_current_session_start(rows, now_ms)
entry = state.get(user, {})
if session_start is None:
# No active session right now — clear tracking so the next
# real session starts fresh.
if entry:
state[user] = {}
continue
if entry.get("session_start") != session_start:
# A new session started (different from whatever we were
# tracking) — start the countdown over.
state[user] = {"session_start": session_start, "notified": False}
entry = state[user]
elapsed_minutes = (now_ms - session_start) / 60000
if not entry.get("notified") and elapsed_minutes >= NOTIFY_DELAY_MINUTES:
send_ntfy(f"{user} started studying {NOTIFY_DELAY_MINUTES} minutes ago and is still going.")
entry["notified"] = True
save_notify_state(state)
time.sleep(POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
threading.Thread(target=notifier_loop, daemon=True).start()
app.run(host="0.0.0.0", port=5000)
PYEOF
backup_if_exists Dockerfile
cat > Dockerfile << 'DOCKEREOF'
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir flask requests
COPY app.py .
CMD ["python3", "app.py"]
DOCKEREOF
backup_if_exists docker-compose.yml
cat > docker-compose.yml << COMPOSEEOF
name: $CONTAINER
services:
$CONTAINER:
build: .
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
environment:
- ANKI_DATA_DIR=/anki-data
- STATE_FILE=/app/state/notify_state.json
volumes:
# Read-only — this container only ever reads collection files (see
# app.py's read_revlog_ids_eases, which opens SQLite in mode=ro),
# never writes, so it can't corrupt live data the sync server or a
# client is using.
- $SYNC_DATA_DIR:/anki-data:ro
- ./state:/app/state
ports:
- "${WEB_PORT}:5000"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
COMPOSEEOF
backup_if_exists .env
cat > .env << ENVEOF
NTFY_URL=$NTFY_URL
NTFY_TOPIC=$NTFY_TOPIC
SESSION_GAP_MINUTES=$SESSION_GAP_MINUTES
NOTIFY_DELAY_MINUTES=$NOTIFY_DELAY_MINUTES
ENVEOF
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AP_DIR"
echo ""
log_success "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $AP_DIR (port $WEB_PORT)"
log_info "Monitoring: $SYNC_INSTANCE"
local START=""
prompt_yn "Start Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d --build \
&& log_success "Anki Progress Dashboard started" \
|| log_warning "Start failed — check: docker compose logs"
fi
# ── Caddy + Authelia-aware protection ────────────────────────────────────
# This dashboard shows every account's personal study activity — same
# "sensitive, protect by default" reasoning as
# services/security-dashboard.sh: auto-use local Authelia if present, no
# prompt needed; otherwise warn clearly and offer a remote instance,
# since leaving it open is a real privacy tradeoff, not a neutral default.
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
EXTRA_BLOCK=" import authelia"
log_info "Local Authelia detected — protecting with it."
else
log_warning "No local Authelia found. This dashboard shows every account's"
log_warning "personal study activity — recommend protecting it before"
log_warning "exposing it publicly."
local _use_remote=""
prompt_yn " Protect with a remote Authelia instance (e.g. on a homelab)? (y/n):" "y" _use_remote
if [[ "$_use_remote" =~ ^[Yy]$ ]]; then
local _remote_authelia=""
prompt_text " Remote Authelia address (bare host:port on a private network, or a full https:// URL on its own public domain+TLS):" "" _remote_authelia
if [ -n "$_remote_authelia" ]; then
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}"
fi
fi
fi
configure_caddy_for_service "Anki Progress Dashboard${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5000" "anki-progress${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" "$EXTRA_BLOCK"
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] \
&& _authelia_scope_access "anki-progress" "$CADDY_SERVICE_DOMAIN"
write_readme "$AP_DIR" << MD
# Anki Progress Dashboard${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Read-only study-progress dashboard for the accounts on **$SYNC_INSTANCE**
(reviews today/this week, accuracy, streak, last active), plus an ntfy
notification sent ${NOTIFY_DELAY_MINUTES} minutes after a study session
starts — defined as the first review after ${SESSION_GAP_MINUTES}+ minutes
of inactivity, and only sent if the session is still going at that point
(not on every single review, and not for a session that's already over).
Reads collection files directly with SQLite's read-only mode — never opens
them for write, so it can't corrupt or interfere with the live sync server
or any client actively syncing.
## Access
- URL: $( [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
## Config
- \`$AP_DIR/.env\` — ntfy URL/topic, session-gap and notify-delay minutes
- Edit and \`docker compose up -d\` to apply changes (no rebuild needed —
these are read at container start from environment variables)
## Manage
\`\`\`bash
cd $AP_DIR
docker compose up -d --build
docker compose down
docker compose logs -f
\`\`\`
MD
}
# ── Standalone execution ───────────────────────────────────────────────────
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
install_anki-progress
fi
+82
View File
@@ -0,0 +1,82 @@
## Client setup — pointing Anki at this server instead of AnkiWeb
Every client below needs the **Sync URL** and one of the **accounts** shown
higher up in this README. Do this on every device you want synced — a client
still pointed at AnkiWeb won't see collections synced here, and vice versa.
### Anki Desktop (2.1.66 and newer)
1. **Preferences → Network**
2. Tick **"Self-hosted sync server"**
3. Paste the Sync URL into the field that appears
4. **Sync → log in** with one of the accounts above
### Anki Desktop (older than 2.1.66)
There's no GUI field yet — set an environment variable before launching Anki
instead, then sync normally:
```bash
# Linux/macOS
export SYNC_ENDPOINT="https://your-sync-url/"
anki
# Windows (Command Prompt)
set SYNC_ENDPOINT=https://your-sync-url/
anki.exe
```
Upgrading Anki to 2.1.66+ is the easier long-term fix — do that if you're
setting this up for anyone who isn't comfortable with environment variables.
### AnkiDroid
**Settings → Advanced → Custom sync server**, then enter the Sync URL and
log in with one of the accounts above (AnkiDroid 2.16+; update the app if
this option isn't there).
### AnkiMobile (iOS)
**Settings → Advanced → Custom Sync Server**, same as AnkiDroid — enter the
Sync URL and log in.
### First sync on each device
The very first sync from a device that already has a local collection will
ask whether to upload local data or download from the server — pick upload
from whichever device has your real collection, and download on every other
device, or you'll end up with two different collections that never merge.
## Importing your existing Quizlet sets
This server only handles syncing already-existing Anki collections — it
doesn't import anything itself. Quizlet import happens once, locally, in the
Anki desktop app, before your first sync:
1. **In Quizlet:** open the set → **Export** → choose the plain-text /
tab-separated format (Quizlet's export dialog lets you pick the delimiter
between term and definition, and between rows — tab and newline are the
Anki-friendly defaults) → copy the exported text or download it as a
`.txt`/`.csv` file.
2. **In Anki Desktop:** **File → Import**, pick the file (or paste the text
into a `.txt` file first if you copied it to the clipboard).
3. Map the two columns to **Front** and **Back** in the import dialog, pick
or create the deck and note type, and import.
4. For **math facts or other simple front/back cards**, the Basic note type
is enough. For **more complex cards** (extra example fields, images,
audio, cloze deletions), switch the note type in the import dialog to a
template with more fields, or convert cards afterward — Anki's own
built-in note types (Basic, Basic (and reversed card), Cloze) cover most
of what Quizlet's own card types can do.
5. Sync from this device once the import looks right, so the imported deck
becomes the copy every other device downloads.
### Exporting back out (Anki → Quizlet or anywhere else)
**File → Export**, choose "Notes in Plain Text" and pick the deck — this
produces the same tab-separated format Quizlet's own import expects, so the
round trip works in both directions.
## Why spaced repetition here actually reschedules failed cards
Anki's scheduler (FSRS, the default since recent Anki versions) tracks a
per-card memory-strength estimate and schedules the next review right before
you'd be expected to forget it. Answering "Again" on a card doesn't just
requeue it for later the same session — it lowers that card's estimated
strength, which shortens every subsequent interval for it until you've
proven you know it again, so a card you keep failing gets shown far more
often than one you consistently get right. This is scheduling logic inside
the Anki client itself; this sync server only stores and syncs the resulting
review history, it doesn't change how reviews are scheduled.
+669
View File
@@ -0,0 +1,669 @@
#!/bin/bash
# services/anki-sync-server.sh — Self-hosted Anki flashcard sync server.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash anki-sync-server.sh
# (Docker must already be installed when run standalone)
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Already installed."
read -r -p " (u)pdate / (f)resh reinstall / (c)ancel [c]: " _r
case "${_r,,}" in
u|update) eval "$_var='update'" ;;
f|fresh) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
generate_password() {
local length="${1:-32}"
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
# Determine mode: local Caddy, remote Caddy, or none
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
# Domain prompt — pre-fill from SITE_DOMAIN when available
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Build upstream — remote Caddy uses host IP:port, not container name
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
log_info "Copy to Caddy machine:"
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service anki-sync-server utilities "Self-hosted Anki flashcard sync server (spaced repetition, syncs across devices without AnkiWeb)" 8080
# Reads the current ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs out of an
# instance's .env into the caller's ANKI_USERS/ANKI_PASSWORDS arrays (bash's
# dynamic scoping means a `local` array declared in the caller is visible
# here without being passed explicitly — same assumption every other helper
# below makes). Numbering is always kept contiguous from 1 by
# _anki_rewrite_account_block, so stopping at the first missing index is
# safe — there's never a gap to skip over.
_anki_load_accounts() {
local _dir="$1" _n=1 _u _p
ANKI_USERS=() ANKI_PASSWORDS=()
while true; do
_u="$(grep "^ANKI_SYNC_USER${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
[ -z "$_u" ] && break
_p="$(grep "^ANKI_SYNC_PASSWORD${_n}=" "$_dir/.env" 2>/dev/null | cut -d= -f2-)"
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$_p")
_n=$((_n + 1))
done
}
# Regenerates the SYNC_USERn=... lines in docker-compose.yml and the
# matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn pairs in .env from the
# caller's current ANKI_USERS/ANKI_PASSWORDS arrays (always renumbered
# contiguously from 1 — see _anki_load_accounts). Used by both the initial
# install and every account-management mutation (add/remove/rotate) so the
# two never drift apart, same reasoning as CLAUDE.md's shared-helper
# guidance for update vs. fresh-install codepaths. Leaves the port, Caddy
# block, and every other line in either file untouched — only lines
# matching the SYNC_USER/ANKI_SYNC_* patterns are touched.
_anki_rewrite_account_block() {
local _dir="$1"
local _compose="$_dir/docker-compose.yml"
local _env="$_dir/.env"
sed -i '/^ - SYNC_USER[0-9]\+=/d' "$_compose"
sed -i '/^ANKI_SYNC_USER[0-9]\+=/d; /^ANKI_SYNC_PASSWORD[0-9]\+=/d' "$_env"
local _compose_lines="" _env_lines="" i idx
for i in "${!ANKI_USERS[@]}"; do
idx=$((i + 1))
_compose_lines+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
"
_env_lines+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
"
done
# Insert right after the fixed SYNC_BASE anchor line — always present,
# written by every version of this script's install flow — instead of
# appending at the end, so the block stays grouped with SYNC_HOST/
# SYNC_PORT/SYNC_BASE rather than drifting after `volumes:`.
local _tmp
_tmp="$(mktemp)"
printf '%s' "$_compose_lines" > "$_tmp"
sed -i "\|^ - SYNC_BASE=/data\$|r $_tmp" "$_compose"
rm -f "$_tmp"
printf '%s' "$_env_lines" >> "$_env"
}
# Interactive add/remove/rotate menu for an existing instance's sync
# accounts, offered from install_anki-sync-server's "already installed"
# menu. Every mutation restarts the container (`docker compose up -d`
# re-reads .env for the new/removed/rotated credentials) but never touches
# the port, Caddy config, or the image — the things CLAUDE.md's "update vs.
# fresh reinstall" convention says a non-destructive path must leave alone.
_anki_manage_accounts() {
local _dir="$1"
local ANKI_USERS=() ANKI_PASSWORDS=()
while true; do
_anki_load_accounts "$_dir"
echo ""
echo " Current sync accounts:"
local i
for i in "${!ANKI_USERS[@]}"; do
echo " $((i + 1))) ${ANKI_USERS[$i]}"
done
[ "${#ANKI_USERS[@]}" -eq 0 ] && echo " (none)"
echo ""
echo " a) Add an account"
echo " r) Remove an account"
echo " p) Rotate (reset) an account's password"
echo " 0) Done"
echo ""
local ACTION=""
prompt_text " Choice [a/r/p/0]:" "0" ACTION
case "$ACTION" in
a|A)
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
log_warning "That's plenty — stopping at 8 accounts."
continue
fi
local _u=""
prompt_text " New username:" "" _u
if [ -z "$_u" ]; then
log_warning "Name can't be empty."; continue
fi
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$(generate_password 24)")
_anki_rewrite_account_block "$_dir"
( cd "$_dir" && docker compose up -d ) \
&& log_success "Account '$_u' added — password: ${ANKI_PASSWORDS[-1]} (also saved in $_dir/.env)" \
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
;;
r|R)
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
log_warning "No accounts to remove."; continue
fi
local _n=""
prompt_text " Remove which number?" "" _n
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
log_warning "Invalid choice."; continue
fi
local _removed="${ANKI_USERS[$((_n - 1))]}"
unset 'ANKI_USERS[_n - 1]' 'ANKI_PASSWORDS[_n - 1]'
ANKI_USERS=("${ANKI_USERS[@]}")
ANKI_PASSWORDS=("${ANKI_PASSWORDS[@]}")
_anki_rewrite_account_block "$_dir"
( cd "$_dir" && docker compose up -d ) \
&& log_success "Account '$_removed' removed" \
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
;;
p|P)
if [ "${#ANKI_USERS[@]}" -eq 0 ]; then
log_warning "No accounts yet."; continue
fi
local _n=""
prompt_text " Rotate password for which number?" "" _n
if ! [[ "$_n" =~ ^[0-9]+$ ]] || [ "$_n" -lt 1 ] || [ "$_n" -gt "${#ANKI_USERS[@]}" ]; then
log_warning "Invalid choice."; continue
fi
ANKI_PASSWORDS[$((_n - 1))]="$(generate_password 24)"
_anki_rewrite_account_block "$_dir"
( cd "$_dir" && docker compose up -d ) \
&& log_success "New password for '${ANKI_USERS[$((_n - 1))]}': ${ANKI_PASSWORDS[$((_n - 1))]} (also saved in $_dir/.env)" \
|| log_warning "Container restart failed — check: docker compose -f $_dir/docker-compose.yml logs"
;;
0)
break
;;
*)
log_warning "Unrecognized choice."
;;
esac
done
}
install_anki-sync-server() {
require_docker || return 1
log_info "Installing Anki Sync Server..."
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "anki-sync-server" name/paths/port exactly
# as before (zero behavior change for anyone with a single instance). Only
# asking to add a second one introduces suffixed naming — same pattern as
# services/ntfy.sh and services/homebox.sh. A second instance is a real
# use case here (e.g. a second household wanting fully separate data on
# the same box) even though one instance already supports multiple
# independent accounts via SYNC_USER1/SYNC_USER2/... — see CLAUDE.md's
# "Multi-instance services" section.
local ANKI_DIR="$DOCKER_DIR/anki-sync-server"
local INSTANCE_SUFFIX="" CONTAINER="anki-sync-server"
local WEB_PORT="8080"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
echo "[DRY-RUN] Would create $ANKI_DIR(-<name>)"
echo "[DRY-RUN] Would prompt for one or more sync accounts and generate passwords"
echo "[DRY-RUN] Would write docker-compose.yml and .env"
echo "[DRY-RUN] Would auto-scan for a free host port"
return 0
fi
if [ -d "$ANKI_DIR" ]; then
echo ""
echo " Anki Sync Server is already installed at $ANKI_DIR."
echo " 1) Manage sync accounts (add / remove / rotate a password — doesn't"
echo " touch the port, Caddy, or the image)"
echo " 2) Manage that install (update image / full reinstall / cancel)"
echo " 3) Add a NEW, separate Anki Sync Server instance alongside it (its"
echo " own data and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2/3]:" "2" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "1" ]; then
_anki_manage_accounts "$ANKI_DIR"
return 0
elif [ "$_TOP_CHOICE" = "3" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'family'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/anki-sync-server-$_suffix" ]; then
log_warning "anki-sync-server-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
ANKI_DIR="$DOCKER_DIR/anki-sync-server-$_suffix"
CONTAINER="anki-sync-server-$_suffix"
log_info "New instance: $ANKI_DIR"
else
# "Manage that install" on THIS instance — the banner above promises
# update/fresh/cancel, so actually offer it instead of falling straight
# through into the same unconditional-overwrite flow as a new install.
if [[ -f "$ANKI_DIR/docker-compose.yml" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing the Anki Sync Server image only — existing accounts, port, and Caddy setup are left as-is."
# The image is a Google distroless "nonroot" build (fixed UID/GID
# 65532, no shell — it can't chown anything itself at startup), so
# ./data has to already be writable by that exact UID or the
# container fails to start. Versions of this installer before this
# fix chowned it to ACTUAL_USER instead, which the container can't
# write to — re-asserting the correct ownership here repairs any
# install made under that bug, non-destructively (it's the
# installer's own bug being corrected, not a config choice, so it
# belongs in the non-destructive update path).
chown -R 65532:65532 "$ANKI_DIR/data" 2>/dev/null
( cd "$ANKI_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Anki Sync Server image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $ANKI_DIR/docker-compose.yml logs"
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;; # fall through to the full install flow below
esac
fi
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
# ── Sync accounts ─────────────────────────────────────────────────────────
# The official sync server has no signup flow of its own — accounts are
# fixed credentials baked in as SYNC_USER1, SYNC_USER2, ... at container
# start, one per line in .env. Ask for at least one now (each Anki client
# — desktop, AnkiDroid, AnkiMobile — logs in with one of these) and offer
# to add more for other people sharing this box, since a single instance
# already keeps each account's collection completely separate.
local ANKI_USERS=() ANKI_PASSWORDS=()
local _u=""
prompt_text " Username for your Anki sync account:" "$ACTUAL_USER" _u
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$(generate_password 24)")
while true; do
local _more=""
prompt_yn " Add another Anki sync account (e.g. for a family member)? (y/n):" "n" _more
[[ "$_more" =~ ^[Yy]$ ]] || break
prompt_text " Username for the additional account:" "" _u
if [ -z "$_u" ]; then
log_warning "Name can't be empty."; continue
fi
ANKI_USERS+=("$_u")
ANKI_PASSWORDS+=("$(generate_password 24)")
if [ "${#ANKI_USERS[@]}" -ge 8 ]; then
log_warning "That's plenty — stopping at 8 accounts."
break
fi
done
mkdir -p "$ANKI_DIR/data"
ensure_docker_dir_ownership "$ANKI_DIR"
cd "$ANKI_DIR" || return 1
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
# remote Caddy box can't resolve container names on this host's bridge
# network anyway; it reaches this service via the host's published port.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
# Build the SYNC_USERn=... lines for docker-compose.yml (compose-time
# interpolation of ${ANKI_SYNC_USERn}/${ANKI_SYNC_PASSWORDn} from .env —
# same \${VAR} pattern services/homebox.sh uses for its own .env values)
# and the matching ANKI_SYNC_USERn/ANKI_SYNC_PASSWORDn lines for .env.
local _COMPOSE_USER_LINES="" _ENV_USER_LINES="" i idx
for i in "${!ANKI_USERS[@]}"; do
idx=$((i + 1))
_COMPOSE_USER_LINES+=" - SYNC_USER${idx}=\${ANKI_SYNC_USER${idx}}:\${ANKI_SYNC_PASSWORD${idx}}
"
_ENV_USER_LINES+="ANKI_SYNC_USER${idx}=${ANKI_USERS[$i]}
ANKI_SYNC_PASSWORD${idx}=${ANKI_PASSWORDS[$i]}
"
done
backup_if_exists docker-compose.yml
cat > docker-compose.yml << ANKI_COMPOSE
name: $CONTAINER
services:
anki-sync-server:
image: afrima/anki-sync-server:latest
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
environment:
- SYNC_HOST=0.0.0.0
- SYNC_PORT=8080
- SYNC_BASE=/data
${_COMPOSE_USER_LINES} volumes:
- ./data:/data
ports:
- "${WEB_PORT}:8080"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
ANKI_COMPOSE
backup_if_exists .env
cat > .env << ANKI_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
CADDY_NET=$SITE_CADDY_NET
# One username/password pair per Anki sync account (SYNC_USER1, SYNC_USER2,
# ... in docker-compose.yml). Enter these exact values as the account on
# each Anki client (Preferences/Settings → self-hosted sync server). To add,
# remove, or reset one of these later, re-run this installer against the
# existing install and pick "Manage sync accounts" — don't hand-edit these
# lines, the matching docker-compose.yml lines have to change in lockstep.
${_ENV_USER_LINES}
ANKI_ENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ANKI_DIR"
# afrima/anki-sync-server is built on gcr.io/distroless/static-debian12:nonroot
# — the process always runs as that image's fixed "nonroot" UID/GID (65532),
# never as ACTUAL_USER, and distroless has no shell so nothing inside the
# container can chown its own data dir at startup. Applied AFTER the
# ACTUAL_USER chown above (not before — that call would just clobber it,
# since it recurses over the whole $ANKI_DIR including data/) so ./data ends
# up owned by 65532 specifically while docker-compose.yml/.env/README.md
# (which the sysadmin edits, not the container) stay owned by ACTUAL_USER.
# Confirmed live: getting this wrong is exactly what makes the container
# fail to come up with a permissions error the moment it tries to create
# anything under /data (e.g. a new user's collection).
chown -R 65532:65532 "$ANKI_DIR/data"
echo ""
log_success "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $ANKI_DIR (port $WEB_PORT)"
echo ""
echo " Sync accounts (also saved in $ANKI_DIR/.env):"
for i in "${!ANKI_USERS[@]}"; do
echo " ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"
done
echo ""
# No Authelia gate here, unlike most other web-facing services in this
# repo: this is a raw HTTP sync API that the Anki client itself talks to
# (not a browser session), so a forward_auth login portal in front of it
# would just break every sync request instead of protecting anything.
# SYNC_USER1/SYNC_USER2/... above is this service's own auth boundary —
# same reasoning as the has-built-in-auth services in CLAUDE.md, just
# with no web UI to additionally gate.
configure_caddy_for_service "Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "anki${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
local START=""
prompt_yn "Start Anki Sync Server${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "Anki Sync Server started" \
|| log_warning "Start failed — check: docker compose logs"
fi
write_readme "$ANKI_DIR" << MD
# Anki Sync Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted sync server for the [Anki](https://apps.ankiweb.net/) flashcard
app — syncs your collection across devices without going through AnkiWeb.
Anki's own spaced-repetition scheduler (FSRS) gives failed cards more
repetition and correctly-recalled cards longer gaps automatically; nothing
here changes that, it's purely the sync backend.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own data directory, own
accounts, own port) — not shared collections with another Anki Sync Server
instance.")
## Access
- Sync URL: $( [ -n "${CADDY_SERVICE_CONFIGURED:-}" ] && [ "$CADDY_SERVICE_CONFIGURED" = "true" ] && echo "https://${CADDY_SERVICE_DOMAIN}/" || echo "http://localhost:${WEB_PORT}/" )
- Accounts (username / password):
$(for i in "${!ANKI_USERS[@]}"; do echo " - ${ANKI_USERS[$i]} / ${ANKI_PASSWORDS[$i]}"; done)
Enter the Sync URL and one of the above accounts on each Anki client — see
the client setup section below for exactly where.
## Data
- Collections: \`$ANKI_DIR/data\`
- Credentials: \`$ANKI_DIR/.env\` (readable by $ACTUAL_USER only)
## Manage
\`\`\`bash
cd $ANKI_DIR
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
\`\`\`
To add, remove, or reset the password of a sync account later, re-run the
installer against this install and pick **"Manage sync accounts"** —
don't hand-edit \`.env\`, the matching lines in \`docker-compose.yml\` have
to change alongside it:
\`\`\`bash
sudo ./setup.sh anki-sync-server
\`\`\`
MD
log_info "Full client setup + Quizlet import walkthrough written to $ANKI_DIR/README.md"
}
# ── Standalone execution ───────────────────────────────────────────────────
if [[ "${_RUN_STANDALONE:-0}" == "1" ]]; then
install_anki-sync-server
fi
+97 -23
View File
@@ -242,7 +242,8 @@ install_authelia() {
echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks"
echo " existing sessions for every domain already on this instance)"
echo " 8) Show who has universal vs. service-scoped access"
echo " 9) Change \"Remember me\" session duration (stay logged in longer)"
echo " 9) Change \"Remember me\" session duration (stay logged in longer — also"
echo " raises the inactivity timeout to match, so it can't cut it short)"
echo " 10) Protect an existing site with this instance (pick a local Caddy site,"
echo " or type one on a different box — gates it with a login, same as any"
echo " other service already protected this way)"
@@ -501,7 +502,12 @@ access_control:
session:
name: authelia_session
expiration: 12h
inactivity: 2h
# Matches remember_me below, not a shorter default — an idle timeout
# shorter than remember_me silently cuts a "remembered" session short
# regardless of its own duration. See _authelia_set_remember_me()'s
# comment for the live case this caused. Change both together (that
# function does exactly this) rather than one at a time.
inactivity: 7d
remember_me: 7d
cookies:
- domain: ${AUTHELIA_DOMAIN}
@@ -1356,6 +1362,29 @@ _authelia_gen_temp_password() {
| fold -w1 | shuf | tr -d '\n'
}
# Lets the admin type a specific password instead of always getting an
# auto-generated one — same masked-input, "[Enter = auto-generate]"
# convention services/backup.sh/borg-backup.sh/koha.sh already use for their
# own passwords, rather than inventing a separate typed-vs-generated menu
# choice here. Sets two out-params (not `local` — read them after the call
# returns, same convention as OIDC_CLIENT_SECRET_PLAIN elsewhere in this
# file): AUTHELIA_CHOSEN_PASSWORD (the plaintext, never written to disk —
# only its argon2 hash is) and AUTHELIA_PASSWORD_AUTO_GENERATED (so callers
# can word their own "here's the password" message correctly either way).
_authelia_prompt_password() {
AUTHELIA_CHOSEN_PASSWORD=""
AUTHELIA_PASSWORD_AUTO_GENERATED=false
local _pw=""
if [ "$UNATTENDED" != true ]; then
read -rsp " Password [Enter = auto-generate]: " _pw; echo
fi
if [ -z "$_pw" ]; then
_pw="$(_authelia_gen_temp_password)"
AUTHELIA_PASSWORD_AUTO_GENERATED=true
fi
AUTHELIA_CHOSEN_PASSWORD="$_pw"
}
# Adds a new user to an EXISTING Authelia instance's users.yml — the scripted
# version of the manual "generate a hash, paste a users.yml block, restart"
# steps this file's own generated README already documents. Non-destructive:
@@ -1376,8 +1405,9 @@ add_authelia_user() {
echo ""
echo " Add a new user to this Authelia instance."
echo " They log in with their username (not email). A temporary password"
echo " is generated below — hand it to them directly. \"Forgot Password\""
echo " They log in with their username (not email). You'll set a password"
echo " next — type your own or leave it blank to auto-generate one — shown"
echo " once here either way, never stored in plaintext. \"Forgot Password\""
echo " and Authelia's own Settings → Change Password both require working"
echo " SMTP (both email a one-time code), so until that's fixed, use this"
echo " menu's \"Edit an existing user\" → \"Reset password\" for future resets."
@@ -1399,9 +1429,9 @@ add_authelia_user() {
local NEW_ADMIN_YN=""
prompt_yn " Grant admin group membership too? (y/n):" "n" NEW_ADMIN_YN
log_info "Generating temporary password + hash..."
local TEMP_PASS NEW_HASH
TEMP_PASS="$(_authelia_gen_temp_password)"
_authelia_prompt_password
local TEMP_PASS="$AUTHELIA_CHOSEN_PASSWORD" NEW_HASH
log_info "Generating password hash..."
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
authelia crypto hash generate argon2 --password "$TEMP_PASS" 2>/dev/null \
| grep -oP '(?<=Digest: ).*')
@@ -1439,8 +1469,12 @@ ${GROUPS_BLOCK}"
fi
echo ""
echo " New user: ${NEW_USERNAME}"
echo " Temp password: ${TEMP_PASS}"
echo " New user: ${NEW_USERNAME}"
if [ "$AUTHELIA_PASSWORD_AUTO_GENERATED" = true ]; then
echo " Temp password: ${TEMP_PASS}"
else
echo " Password: ${TEMP_PASS} (the one you just typed)"
fi
echo " Give this to them directly (it's shown once, nothing stores it in"
echo " plaintext). They can log in with it as-is and keep using it, or"
echo " change it themselves from Authelia's Settings page — but that page"
@@ -2355,6 +2389,19 @@ _authelia_report_access_scope() {
# earlier version of this very file's own README section) uses the old
# name, which Authelia would just silently ignore rather than error on.
#
# Also writes the SAME value into `inactivity` — a separate session field
# (default 2h, set alongside remember_me in install_authelia()'s own
# template) that ends a session after that much idle time regardless of
# remember_me, since it isn't disabled or extended by the "Remember me"
# checkbox. Confirmed live: a user who'd set remember_me to 1y still got
# logged out after ordinary daily gaps (overnight, a workday) because
# inactivity was still sitting at its 2h default — remember_me alone does
# NOT deliver "won't be asked to log in again for the duration I set"
# without this. Tying the two together is what actually delivers that.
# `expiration` (the session cap when "Remember me" is NOT checked) is left
# alone — a shorter default there for an un-remembered session is correct,
# separate behavior, not the same gap.
#
# This only controls AUTHELIA's own session — it does not touch how long
# a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts
# after logging in via Authelia. A long remember_me makes re-authenticating
@@ -2365,27 +2412,48 @@ _authelia_set_remember_me() {
local config_file="$DOCKER_DIR/authelia/config/configuration.yml"
[ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; }
local current
local current current_inactivity
current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
current_inactivity="$(grep -E '^ inactivity:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
echo ""
echo " Current \"remember me\" duration: ${current:-not set}"
echo " Current \"remember me\" duration: ${current:-not set} (inactivity timeout: ${current_inactivity:-not set})"
echo " How long a session lasts when someone checks \"Remember me\" at login —"
echo " applies to every domain this Authelia instance protects."
echo " applies to every domain this Authelia instance protects. Also sets"
echo " \"inactivity\" (idle timeout) to the same value, so a gap between visits"
echo " shorter than this can't log you out early — otherwise inactivity's own"
echo " separate, much shorter default cuts a long remember_me short."
echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely."
local new_duration=""
prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration
if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then
if [ -z "$new_duration" ]; then
log_info "No change made."
return 0
fi
# Only truly a no-op if BOTH keys already match — remember_me alone
# matching isn't enough to skip, or an install still carrying the old
# mismatched inactivity default (from before this function synced the
# two) could never actually get inactivity fixed by re-entering the
# same remember_me value. Confirmed live: this is exactly what
# happened on a box that had already set remember_me: 1y before this
# sync existed — re-running with "1y" again hit this early return and
# left inactivity untouched.
if [ "$new_duration" = "$current" ] && [ "$new_duration" = "$current_inactivity" ]; then
log_info "No change made — remember_me and inactivity already both ${new_duration}."
return 0
fi
if grep -qE '^ remember_me:' "$config_file"; then
sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file"
else
sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file"
fi
if grep -qE '^ inactivity:' "$config_file"; then
sed -i "s/^ inactivity:.*/ inactivity: '${new_duration}'/" "$config_file"
else
sed -i "/^ remember_me:/a\\ inactivity: '${new_duration}'" "$config_file"
fi
chown 1000:1000 "$config_file" 2>/dev/null || true
log_success "\"Remember me\" duration set to ${new_duration}."
log_success "\"Remember me\" duration and inactivity timeout both set to ${new_duration}."
local restart_auth=""
prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth
@@ -2397,9 +2465,10 @@ _authelia_set_remember_me() {
echo ""
log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's"
log_info "login page — existing sessions keep whatever expiration they already had."
log_info "The checkbox itself is already on the login form by default; this only"
log_info "changes how long checking it actually keeps you signed in."
log_info "login page — existing sessions keep whatever expiration/inactivity they"
log_info "already had. The checkbox itself is already on the login form by default;"
log_info "this only changes how long checking it actually keeps you signed in, and"
log_info "stops the separate inactivity timeout from cutting that short."
}
# Export/import accounts (+ optionally 2FA/session state) — for migrating to
@@ -2697,7 +2766,7 @@ _authelia_manage_one_user() {
echo ""
echo " Editing user: $TARGET (admin: $IS_ADMIN, 2FA-exempt: $IS_EXEMPT)"
echo " 1) Edit email / display name"
echo " 2) Reset password"
echo " 2) Set/reset password (type your own, or auto-generate)"
echo " 3) Reset 2FA device (they register a new one on next login)"
if [ "$IS_EXEMPT" = "yes" ]; then
echo " 4) Restore the 2FA requirement for this user"
@@ -2729,9 +2798,9 @@ _authelia_manage_one_user() {
log_success "Updated $TARGET's email/display name."
;;
2)
log_info "Generating a new temporary password + hash..."
local NEW_TEMP_PASS NEW_HASH
NEW_TEMP_PASS="$(_authelia_gen_temp_password)"
_authelia_prompt_password
local NEW_TEMP_PASS="$AUTHELIA_CHOSEN_PASSWORD" NEW_HASH
log_info "Generating password hash..."
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
authelia crypto hash generate argon2 --password "$NEW_TEMP_PASS" 2>/dev/null \
| grep -oP '(?<=Digest: ).*')
@@ -2740,8 +2809,13 @@ _authelia_manage_one_user() {
else
_authelia_set_user_field "$USERS_FILE" "$START" "$END" "password" " password: \"${NEW_HASH}\""
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
log_success "Password reset for $TARGET."
echo " New password: ${NEW_TEMP_PASS}"
if [ "$AUTHELIA_PASSWORD_AUTO_GENERATED" = true ]; then
log_success "Password reset for $TARGET (auto-generated)."
echo " New password: ${NEW_TEMP_PASS}"
else
log_success "Password set for $TARGET."
echo " Password: ${NEW_TEMP_PASS} (the one you just typed)"
fi
echo " Give this to them directly — shown once, not stored in plaintext anywhere."
fi
;;
+25
View File
@@ -21,6 +21,7 @@ install_base() {
echo "[DRY-RUN] Would offer to mount SMB data from a NetBird-connected home box (if NetBird is present)"
echo "[DRY-RUN] Would offer Caddy reverse proxy install (full repo only)"
echo "[DRY-RUN] Would offer CrowdSec intrusion prevention install (full repo only)"
echo "[DRY-RUN] Would offer Samba (SMB/CIFS) file sharing install (full repo only)"
echo "[DRY-RUN] Would offer to add SSH Host aliases to ~/.ssh/config"
return 0
fi
@@ -80,6 +81,14 @@ install_base() {
_base_setup_crowdsec
cd "$_BASE_PWD" 2>/dev/null || true
# ── Samba ────────────────────────────────────────────────────────────────
# Same nudge-not-mandatory shape as Caddy/CrowdSec above: fully optional,
# independently re-runnable later via `sudo ./setup.sh samba`. Defaults to
# n (unlike Caddy/CrowdSec) because it needs real input to be useful — a
# share path and at least one user — not just "yes, with sane defaults".
_base_setup_samba
cd "$_BASE_PWD" 2>/dev/null || true
# ── SSH Host aliases ─────────────────────────────────────────────────────
_base_setup_ssh_aliases
@@ -329,6 +338,22 @@ _base_setup_crowdsec() {
install_crowdsec
}
_base_setup_samba() {
if command -v smbd &>/dev/null; then
log_info "Samba already installed."
return 0
fi
# Only available when the full repo is sourced (setup.sh loads every
# services/*.sh up front) — a standalone copy of base.sh doesn't have
# install_samba, so skip silently rather than error.
declare -F install_samba &>/dev/null || return 0
local INSTALL_SAMBA=""
prompt_yn "Install Samba (SMB/CIFS) file sharing now — shares, users, passwords? (y/n):" "n" INSTALL_SAMBA
[[ "$INSTALL_SAMBA" =~ ^[Yy]$ ]] || return 0
install_samba
}
_base_setup_ssh_aliases() {
local ADD_ALIAS=""
prompt_yn "Add an SSH Host alias now ('ssh myserver' instead of 'ssh user@1.2.3.4')? (y/n):" "n" ADD_ALIAS
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
# services/claude-cli.sh — Claude Code CLI: dual-account setup, model/effort
# defaults, and a shared global CLAUDE.md.
#
# Non-Docker (see CLAUDE.md's "Non-Docker services" section). Installs the
# official Claude Code CLI if missing, then wires up:
# - two independent account config directories (work/personal), each its
# own CLAUDE_CONFIG_DIR behind a shell alias, so `claude-work` and
# `claude-personal` are two fully separate logins on one machine
# - one shared, imported global CLAUDE.md (durable personal conventions —
# modular/reuse code, numbered CLI menus with 0=always-exit, verify web
# UI changes with Playwright) that both accounts pull in via `@import`,
# so there's exactly one copy to edit, not two that can drift
# - settings.json defaults applied to both accounts: model pinned to
# claude-sonnet-5, effort level medium, and ENABLE_PROMPT_CACHING_1H=1
# (keeps the 1h prompt-cache lifetime even after usage credits kick in,
# instead of dropping to 5 minutes — see services/ai-stack.md's hybrid
# workflow section for why this pairs with a local-GPU + Claude Code split)
#
# The Anthropic login itself (browser OAuth) can't be scripted — this only
# prepares the directories/aliases/config. Run `claude-work` and
# `claude-personal` once each afterward to actually log each one in.
# Part of the modular post-install system (sourced by setup.sh).
register_service claude-cli extras "Claude Code CLI — dual-account setup (work/personal), model/effort defaults, shared global CLAUDE.md"
install_claude-cli() {
local WORK_DIR="$ACTUAL_HOME/.claude-work"
local PERSONAL_DIR="$ACTUAL_HOME/.claude-personal"
local SHARED_DIR="$ACTUAL_HOME/.claude-shared"
local SHARED_CLAUDE_MD="$SHARED_DIR/CLAUDE.md"
local BASHRC="$ACTUAL_HOME/.bashrc"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install the Claude Code CLI (official installer) if missing"
echo "[DRY-RUN] Would create $WORK_DIR and $PERSONAL_DIR config dirs"
echo "[DRY-RUN] Would write $SHARED_CLAUDE_MD (shared conventions) and import it from each account's CLAUDE.md"
echo "[DRY-RUN] Would write settings.json (model=claude-sonnet-5, effortLevel=medium, ENABLE_PROMPT_CACHING_1H=1) into each account dir"
echo "[DRY-RUN] Would add claude-work/claude-personal aliases to $BASHRC (idempotent)"
return 0
fi
if [ -f "$SHARED_CLAUDE_MD" ]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing shared CLAUDE.md and settings.json only — account dirs/credentials untouched."
_claude_cli_write_shared_claude_md "$SHARED_CLAUDE_MD"
_claude_cli_write_settings "$WORK_DIR/settings.json"
_claude_cli_write_settings "$PERSONAL_DIR/settings.json"
ensure_docker_dir_ownership "$SHARED_DIR" "$WORK_DIR" "$PERSONAL_DIR"
log_success "claude-cli config refreshed"
return 0
;;
cancel)
log_info "Leaving the existing claude-cli setup as-is."
return 0
;;
fresh) ;; # fall through to the full setup below
esac
fi
# ── Install the CLI itself ──────────────────────────────────────────────
if ! command -v claude >/dev/null 2>&1; then
log_info "Installing Claude Code CLI..."
if curl -fsSL https://claude.ai/install.sh | bash; then
log_success "Claude Code CLI installed"
else
log_error "Claude Code CLI install failed — see https://code.claude.com/docs/en/setup"
return 1
fi
else
log_info "Claude Code CLI already installed ($(command -v claude))"
fi
# ── Account config dirs + shared conventions ────────────────────────────
mkdir -p "$WORK_DIR" "$PERSONAL_DIR" "$SHARED_DIR"
_claude_cli_write_shared_claude_md "$SHARED_CLAUDE_MD"
local _dir
for _dir in "$WORK_DIR" "$PERSONAL_DIR"; do
# @import pulls the shared file in at session start (see Claude
# Code's memory docs) — one canonical copy, not two that can drift.
[ -f "$_dir/CLAUDE.md" ] || printf '@%s\n' "$SHARED_CLAUDE_MD" > "$_dir/CLAUDE.md"
_claude_cli_write_settings "$_dir/settings.json"
done
# ── Shell aliases — idempotent, same append-once pattern base.sh uses
# for tab completion (grep-before-append, chown after) ──────────────────
if [ -f "$BASHRC" ] && ! grep -qF "CLAUDE_CONFIG_DIR=$WORK_DIR" "$BASHRC" 2>/dev/null; then
{
echo ""
echo "# ubuntu-post-install: claude-cli dual-account aliases"
echo "alias claude-work='CLAUDE_CONFIG_DIR=$WORK_DIR claude'"
echo "alias claude-personal='CLAUDE_CONFIG_DIR=$PERSONAL_DIR claude'"
} >> "$BASHRC"
chown "$ACTUAL_USER:$ACTUAL_USER" "$BASHRC" 2>/dev/null || true
log_success "Added claude-work / claude-personal aliases to $BASHRC (new shells, or: source $BASHRC)"
fi
ensure_docker_dir_ownership "$SHARED_DIR" "$WORK_DIR" "$PERSONAL_DIR"
echo ""
log_warning "Login still needs a one-time browser step per account — this only prepared the plumbing:"
echo " claude-work # first run: browser OAuth login for your work account"
echo " claude-personal # first run: browser OAuth login for your personal account"
echo ""
echo " Shared conventions : $SHARED_CLAUDE_MD (edit once, both accounts see it)"
echo " Work config : $WORK_DIR"
echo " Personal config : $PERSONAL_DIR"
echo ""
}
# Writes/refreshes the three keys this service owns via jq (preserves any
# other hand-added settings, e.g. permissions); falls back to a fresh file
# if jq is missing (base.sh installs it, but this service can run standalone)
# or the existing file isn't valid JSON.
_claude_cli_write_settings() {
local dest="$1"
local patch='{"model":"claude-sonnet-5","effortLevel":"medium","env":{"ENABLE_PROMPT_CACHING_1H":"1"}}'
if [ -f "$dest" ] && command -v jq >/dev/null 2>&1; then
local merged
merged="$(jq -s '.[0] * .[1]' "$dest" <(echo "$patch") 2>/dev/null)" \
&& [ -n "$merged" ] \
&& printf '%s\n' "$merged" > "$dest" \
&& return 0
log_warning "$dest wasn't valid JSON — leaving it untouched. Merge manually: $patch"
return 0
fi
[ -f "$dest" ] && return 0
echo "$patch" | (command -v jq >/dev/null 2>&1 && jq . || cat) > "$dest"
}
_claude_cli_write_shared_claude_md() {
cat > "$1" << 'EOF'
# Personal conventions (all projects, both accounts)
## Code reuse
Write shared logic once, in one place. Before adding a new function, check
whether an existing one already does it — extend/parameterize rather than
duplicate.
## CLI menus
Every interactive menu is numbered. `0` is always "exit" / "back" — never
reused for another action, and always present, even on a submenu.
## Verifying web UI changes
After any frontend change, drive it with Playwright before calling it done —
navigate the real page, exercise the changed flow, screenshot if the result
is visual. Don't declare a UI task complete from reading the code alone.
EOF
}
+342 -4
View File
@@ -453,6 +453,11 @@ _gitea_remove_sync_timer() {
# reconfigure of an existing one. Always asked (matches pstn-trunk.sh's
# international-calling step reasoning: a live-editable extra, not a
# structural setting tied exclusively to fresh installs).
#
# Sets _GITEA_SYNC_FLAG as an out-param (not `local` — read it after the
# call returns, same convention as CADDY_SERVICE_CONFIGURED) so the caller
# can decide whether the real-time webhook offer even makes sense for the
# direction just chosen.
_gitea_run_sync_direction_step() {
local DIR="$1"
@@ -463,12 +468,13 @@ _gitea_run_sync_direction_step() {
echo " 3) Both directions"
local _DIR_CHOICE=""
prompt_text " Choice [1]:" "1" _DIR_CHOICE
local FLAG="" DIR_DESC=""
local DIR_DESC=""
case "$_DIR_CHOICE" in
2) FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
3) FLAG=""; DIR_DESC="both directions" ;;
*) FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
2) _GITEA_SYNC_FLAG="--push-only"; DIR_DESC="Gitea -> GitHub only" ;;
3) _GITEA_SYNC_FLAG=""; DIR_DESC="both directions" ;;
*) _GITEA_SYNC_FLAG="--pull-only"; DIR_DESC="GitHub -> Gitea only" ;;
esac
local FLAG="$_GITEA_SYNC_FLAG"
log_info "Sync direction: $DIR_DESC"
_gitea_remove_sync_timer
@@ -519,6 +525,250 @@ _gitea_run_sync_direction_step() {
esac
}
# ── Real-time sync: a GitHub webhook receiver, not just the timer above ────
# The timer above polls on a fixed schedule (default 6h) — fine for a slow
# backup cadence, but a genuine "GitHub -> Gitea in real time" ask needs
# GitHub to tell Gitea the moment something changes instead of Gitea finding
# out up to one interval late. GitHub's own webhook (repo Settings ->
# Webhooks) is the standard way to do that: it POSTs a JSON payload the
# instant someone pushes. This writes a tiny stdlib-only Python HTTP server
# to receive it — python3 is already a hard dependency of this directory's
# gitea-github-sync.sh itself (used there for JSON parsing), so this adds
# no new dependency — running under its own persistent systemd service,
# and wires it up to Caddy the same way every other web-facing piece of
# this install does.
#
# Deliberately NOT a Docker container: it just shells out to the existing
# gitea-github-sync.sh sitting right next to it in $DIR, the same way the
# timer's own systemd service does — no image to build/pull for what's
# fundamentally a few lines of stdlib HTTP handling.
_gitea_write_webhook_receiver() {
local DIR="$1"
cat > "$DIR/gitea-github-webhook.py" << 'PYEOF'
#!/usr/bin/env python3
"""Gitea <-> GitHub webhook receiver — triggers an immediate, single-repo
mirror sync (gitea-github-sync.sh --repo owner/name --pull-only) the moment
GitHub POSTs a push event, instead of waiting for the scheduled timer.
Written by services/gitea.sh — re-run 'sudo ./setup.sh gitea' (Update mode
is fine) to regenerate this file rather than hand-editing it; a hand edit
survives until the next Update-mode rerun overwrites it again.
WEBHOOK_SECRET is read from .env in this same directory at every request,
never taken from the environment/systemd unit — /etc/systemd/system/*.service
files are world-readable, and .env (chmod 600) is already where every other
token in this directory lives.
"""
import hashlib
import hmac
import http.server
import json
import os
import subprocess
import sys
SYNC_DIR = os.environ.get("GITEA_SYNC_DIR", os.path.dirname(os.path.abspath(__file__)))
ENV_PATH = os.path.join(SYNC_DIR, ".env")
PORT = int(os.environ.get("WEBHOOK_PORT", "3020"))
def _load_env_value(key):
try:
with open(ENV_PATH, "r") as f:
for line in f:
line = line.split("#", 1)[0].strip()
if not line.startswith(key + "="):
continue
return line[len(key) + 1:].strip().strip("'").strip('"')
except OSError:
pass
return ""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
def _reply(self, code, body=b""):
self.send_response(code)
self.end_headers()
if body:
self.wfile.write(body)
def do_GET(self):
self._reply(200, b"gitea-github-webhook: listening\n")
def do_POST(self):
secret = _load_env_value("WEBHOOK_SECRET").encode()
if not secret:
self._reply(503, b"WEBHOOK_SECRET not configured")
return
length = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(length) if length else b""
sig = self.headers.get("X-Hub-Signature-256", "")
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
if not sig or not hmac.compare_digest(sig, expected):
self._reply(401, b"bad signature")
return
event = self.headers.get("X-GitHub-Event", "")
if event == "ping":
self._reply(200, b"pong")
return
if event != "push":
self._reply(204)
return
try:
payload = json.loads(body or b"{}")
full_name = payload["repository"]["full_name"]
except (json.JSONDecodeError, KeyError, TypeError):
self._reply(400, b"couldn't find repository.full_name in payload")
return
self._reply(202, b"sync queued\n")
sync_script = os.path.join(SYNC_DIR, "gitea-github-sync.sh")
sync_env = dict(os.environ, SYNC_ENV=ENV_PATH)
subprocess.Popen(
["bash", sync_script, "--repo", full_name, "--pull-only"],
cwd=SYNC_DIR,
env=sync_env,
)
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.serve_forever()
PYEOF
chmod +x "$DIR/gitea-github-webhook.py"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/gitea-github-webhook.py"
}
_gitea_write_webhook_service() {
local DIR="$1" RUN_USER="$2" RUN_HOME="$3" PORT="$4"
local _service="/etc/systemd/system/gitea-github-webhook.service"
cat > "$_service" << UNIT
[Unit]
Description=Gitea-GitHub Webhook Receiver (real-time mirror sync trigger)
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=${RUN_USER}
Environment=HOME=${RUN_HOME}
Environment=GITEA_SYNC_DIR=${DIR}
Environment=WEBHOOK_PORT=${PORT}
ExecStart=/usr/bin/python3 ${DIR}/gitea-github-webhook.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now gitea-github-webhook.service
}
_gitea_remove_webhook_service() {
systemctl disable --now gitea-github-webhook.service 2>/dev/null || true
rm -f /etc/systemd/system/gitea-github-webhook.service
systemctl daemon-reload 2>/dev/null || true
}
# Offers the webhook receiver above as an addition to (not a replacement
# for) the timer set up in _gitea_run_sync_direction_step — the timer keeps
# covering the Gitea -> GitHub direction (and acts as a safety net for any
# push GitHub's webhook delivery ever misses), the webhook just gets the
# GitHub -> Gitea direction down from "up to one interval late" to seconds.
# Always asked on every install/reconfigure, same "live-editable extra"
# pattern as the direction+autosync step itself — see that function's own
# comment. Skipped (and any existing webhook torn down) outright when the
# chosen direction is push-only, since GitHub has nothing to notify about
# in that direction.
_gitea_offer_realtime_webhook() {
local DIR="$1" SYNC_FLAG="$2"
if [[ "$SYNC_FLAG" == "--push-only" ]]; then
_gitea_remove_webhook_service
return 0
fi
echo ""
local USE_WEBHOOK=""
prompt_yn " Also add a GitHub webhook for near-instant sync (push on GitHub -> synced here in seconds, instead of waiting for the timer above)? (y/n):" "n" USE_WEBHOOK
if [[ ! "$USE_WEBHOOK" =~ ^[Yy]$ ]]; then
_gitea_remove_webhook_service
return 0
fi
# Reuse an existing secret/port across reruns — rotating either one
# silently breaks a webhook GitHub already has configured against the
# old value, the same reasoning services/asterisk.sh's TURN port-range
# persistence follows for a live coturn install.
local WEBHOOK_SECRET WEBHOOK_PORT
WEBHOOK_SECRET="$(grep '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
WEBHOOK_PORT="$(grep '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d "'\"")"
[[ -z "$WEBHOOK_SECRET" ]] && WEBHOOK_SECRET="$(generate_password 40)"
if [[ -z "$WEBHOOK_PORT" ]]; then
WEBHOOK_PORT=3020
find_free_port WEBHOOK_PORT "$WEBHOOK_PORT"
fi
if grep -q '^WEBHOOK_SECRET=' "$DIR/.env" 2>/dev/null; then
sed -i "s|^WEBHOOK_SECRET=.*|WEBHOOK_SECRET='${WEBHOOK_SECRET}'|" "$DIR/.env"
else
echo "WEBHOOK_SECRET='${WEBHOOK_SECRET}'" >> "$DIR/.env"
fi
if grep -q '^WEBHOOK_PORT=' "$DIR/.env" 2>/dev/null; then
sed -i "s|^WEBHOOK_PORT=.*|WEBHOOK_PORT='${WEBHOOK_PORT}'|" "$DIR/.env"
else
echo "WEBHOOK_PORT='${WEBHOOK_PORT}'" >> "$DIR/.env"
fi
chmod 600 "$DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env"
_gitea_write_webhook_receiver "$DIR"
_gitea_write_webhook_service "$DIR" "$ACTUAL_USER" "$ACTUAL_HOME" "$WEBHOOK_PORT"
log_success "Webhook receiver running on port ${WEBHOOK_PORT} (systemctl status gitea-github-webhook)."
# Bare port -> host.docker.internal:PORT, same convention as every other
# host-process (non-container) upstream in this repo — see the
# configure_caddy_for_service usage note in CLAUDE.md.
configure_caddy_for_service "Gitea GitHub Webhook" "$WEBHOOK_PORT" "gitea-webhook"
if [[ "$CADDY_SERVICE_CONFIGURED" == true ]]; then
if command -v ufw &>/dev/null; then
if [[ "$CADDY_SERVICE_MODE" == "local" ]]; then
ufw delete allow "${WEBHOOK_PORT}/tcp" 2>/dev/null || true
ufw_allow_from_caddy_net "${WEBHOOK_PORT}"
else
ufw allow "${WEBHOOK_PORT}/tcp" comment "Gitea GitHub webhook" >/dev/null 2>&1 || true
ensure_ufw_enabled
fi
fi
echo ""
log_success "Now add the webhook on GitHub, for every repo you want instant sync from:"
log_info " Repo -> Settings -> Webhooks -> Add webhook"
log_info " Payload URL: https://${CADDY_SERVICE_DOMAIN}/"
log_info " Content type: application/json"
log_info " Secret: ${WEBHOOK_SECRET}"
log_info " Events: Just the push event"
log_info "The timer above still covers every other repo, and this one too, on its"
log_info "own schedule — the webhook is an addition, not a replacement for it."
else
log_warning "Webhook receiver is running (0.0.0.0:${WEBHOOK_PORT}) but nothing is exposing"
log_warning "it to the internet, so GitHub can't reach it yet — re-run this installer and"
log_warning "configure Caddy for it, or point your own reverse proxy at"
log_warning "127.0.0.1:${WEBHOOK_PORT} (or the container-reachable host IP) by hand."
log_info " Secret (for whenever you do expose it): ${WEBHOOK_SECRET}"
fi
}
install_gitea() {
log_info "Setting up self-hosted Gitea..."
@@ -529,12 +779,16 @@ install_gitea() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml (gitea/gitea:latest)"
echo "[DRY-RUN] Would scan for free host ports (web + SSH) to avoid collisions"
echo "[DRY-RUN] Would open the SSH clone port in UFW (web port too, or scoped to caddy_net"
echo "[DRY-RUN] if Caddy ends up fronting it locally)"
echo "[DRY-RUN] Would prompt for a Gitea admin username/password, then create that account"
echo "[DRY-RUN] and an API token once the container is ready (no manual web wizard)"
echo "[DRY-RUN] Would prompt for a GitHub token and copy in gitea-github-sync.sh"
echo "[DRY-RUN] Would ask sync direction (GitHub->Gitea / Gitea->GitHub / both) and whether"
echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions"
echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule"
echo "[DRY-RUN] Would offer a GitHub webhook receiver for near-instant GitHub->Gitea sync"
echo "[DRY-RUN] (systemd service + Caddy front door), unless direction is push-only"
echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
echo "[DRY-RUN] Would offer zero-click Authelia login (reverse-proxy auth) if Authelia"
echo "[DRY-RUN] and local Caddy are both installed — rewires Gitea onto caddy_net"
@@ -565,6 +819,7 @@ install_gitea() {
&& log_success "Gitea refreshed and restarted." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
_gitea_run_sync_direction_step "$DIR"
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR"
@@ -730,6 +985,7 @@ ENV
fi
_gitea_run_sync_direction_step "$DIR"
_gitea_offer_realtime_webhook "$DIR" "$_GITEA_SYNC_FLAG"
# ── Caddy — no forward_auth gate here. Gitea has its own built-in login,
# unlike the no-auth-at-all apps elsewhere in this repo that need Caddy
@@ -738,6 +994,25 @@ ENV
# replacement requiring Caddy involvement. ─────────────────────────────
configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git"
# ── Firewall ─────────────────────────────────────────────────────────────
# SSH clone (SSH_PORT->22) is a different protocol than the web UI — Caddy
# can't front it no matter what CADDY_SERVICE_MODE came back as, so it
# always needs its own direct rule or `git clone ssh://...` hangs forever
# (a dropped SYN with UFW active, not a fast connection-refused).
if command -v ufw &>/dev/null; then
if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then
ufw delete allow "${WEB_PORT}/tcp" 2>/dev/null || true
ufw_allow_from_caddy_net "${WEB_PORT}"
else
ufw allow "${WEB_PORT}/tcp" comment "Gitea web UI" >/dev/null 2>&1 || true
fi
ufw allow "${SSH_PORT}/tcp" comment "Gitea SSH clone" >/dev/null 2>&1 || true
ensure_ufw_enabled
log_success "UFW: opened SSH clone port ${SSH_PORT}/tcp"
else
log_warning "ufw not installed — if you use a firewall, open TCP ${SSH_PORT} for SSH clones."
fi
_gitea_offer_authelia_sso "$DIR"
_gitea_offer_reverse_proxy_auth "$DIR"
_gitea_offer_actions_runner "$DIR"
@@ -773,6 +1048,69 @@ Config (which repos, private/forks handling) lives at
\`~/.config/gitea-github-sync/config\` — edit directly, or re-run
\`bash gitea-github-sync.sh --init\` to redo it interactively.
## Real-time sync via GitHub webhook (optional)
The setup above only covers the GitHub -> Gitea direction; it doesn't apply
if you chose Gitea -> GitHub only (GitHub has nothing to notify about in
that direction). Adds a small Python HTTP server
(\`gitea-github-webhook.py\`, in this directory) run as its own systemd
service (\`gitea-github-webhook.service\`) that GitHub POSTs to the instant
someone pushes — it verifies the request's HMAC signature against
\`WEBHOOK_SECRET\` in \`.env\`, then runs \`gitea-github-sync.sh --repo
owner/name --pull-only\` for just that one repo. The scheduled timer above
still runs on its own interval regardless — the webhook is an addition
that gets the GitHub -> Gitea direction down to seconds, not a replacement
for it (and still catches anything a missed webhook delivery would have
picked up next interval anyway).
Not set up yet, or want to change the port/secret? Re-run
\`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to "Also add
a GitHub webhook...". That only stands up the *receiver* on this box — you
still add the actual webhook on GitHub's side afterward, using the payload
URL and secret the installer printed (also readable back from \`.env\` as
\`WEBHOOK_PORT\` / \`WEBHOOK_SECRET\` if you need them again).
**Option A — one repo at a time.** Fastest, but only covers repos you do
this for individually:
repo -> Settings -> Webhooks -> Add webhook
- Payload URL: the URL the installer printed
- Content type: \`application/json\`
- Secret: your \`WEBHOOK_SECRET\`
- Events: "Just the push event"
**Option B — every repo on your account, current AND future, from one
setup.** A plain repo webhook (Option A) is always per-repo, no way around
that — but a personal GitHub App installed with "All repositories" access
covers every repo automatically, including ones you create afterward. No
receiver/code change needed for this: an App's webhook uses the exact same
HMAC-secret mechanism as a repo webhook, so the same \`WEBHOOK_SECRET\`
works for both.
1. GitHub -> Settings -> Developer settings -> GitHub Apps -> New GitHub App
2. Webhook URL: same payload URL as Option A. Webhook secret: your
\`WEBHOOK_SECRET\`. (Homepage URL is a separate, purely cosmetic field —
point it at anything, e.g. your GitHub profile; GitHub never sends
anything there, unlike Webhook URL.)
3. Permissions -> Repository permissions -> Contents: Read-only (required
to unlock the Push event checkbox)
4. Subscribe to events: Push only
5. Where can this GitHub App be installed: "Only on this account"
6. Create it, then Install App -> choose "All repositories" -> Install
If you'd already added Option A webhooks on a few repos, they're now
redundant (not harmful, just two triggers per push) — remove them once
the App is confirmed working.
**Verify either option** — push to a repo, then watch it arrive:
\`\`\`bash
systemctl status gitea-github-webhook # is it running?
journalctl -u gitea-github-webhook -f # watch it receive + trigger syncs
\`\`\`
GitHub also shows delivery attempts and response codes: repo (or App) ->
Settings -> Webhooks -> the webhook -> Recent Deliveries.
## Sign in with Authelia (optional)
If Authelia is installed, re-run \`sudo ./setup.sh gitea\` (Update mode is
+54
View File
@@ -0,0 +1,54 @@
## "Please select an entity type" with an empty type dropdown
Some collections hit an upstream Homebox bug
([sysadminsmedia/homebox#1593](https://github.com/sysadminsmedia/homebox/issues/1593)):
the default `Location`/`Item` entity types never get seeded, so the Create
dialog's type dropdown comes up empty and every Location/Item creation fails
with "Please select an entity type."
### Option 1 — let the installer fix it
Re-run `sudo ./setup.sh homebox`, choose **update**, and say yes when asked
"Hit 'Please select an entity type' with an empty type list...?" You'll need
a Homebox API token (see step 2 below) — it's used once, right then, and
never written to `.env` or disk.
This only fixes the ONE collection that token's account belongs to. Homebox
has no documented way to switch a token between collections, so a
multi-collection setup needs this repeated once per collection — not
something the installer can do for you in one pass, and not really worth
the trouble if you've only hit this on one collection already.
### Option 2 — fix it by hand in the UI
1. Log into Homebox (register first if you haven't — the first account
becomes the admin).
2. Click the **collection selector** (shows your current collection's name,
near the top of the page) → **Collection options** (gear icon). This
opens Collection Settings.
3. Click the **Entity Types** tab (`/collection/entity-types`).
4. Click **Create**, add:
- Name: `Location`, with **Is Location** toggled **ON**
- Name: `Item`, with **Is Location** toggled **OFF**
5. Go back to the Create (+) dialog — "Select a type..." now lists both, so
you can create Locations and Items normally.
If you use more than one collection, repeat step 24 once per collection —
entity types are scoped per collection, not shared across your whole
Homebox instance.
### Option 3 — fix it via the API directly
Needs an API token from your profile menu (**Create API Token**):
```bash
curl -X POST "http://localhost:<port>/api/v1/entity-types" \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Location","isLocation":true}'
curl -X POST "http://localhost:<port>/api/v1/entity-types" \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{"name":"Item","isLocation":false}'
```
+82
View File
@@ -319,6 +319,79 @@ _homebox_offer_disable_local_login() {
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
}
# Some Homebox installs hit an upstream bug (sysadminsmedia/homebox#1593): a
# collection's default "Location"/"Item" entity types never get seeded, so
# the Create dialog's type dropdown comes up empty and every Location/Item
# creation fails with "Please select an entity type". There's no
# unauthenticated way to detect or fix this — entity types are scoped per
# collection (confirmed against Homebox's own swagger doc: GET/POST
# /v1/entity-types both require a bearer token) — so this is opt-in and
# asks for a token at the moment it runs, same trust model as Immich's
# _immich_offer_authelia_oidc(): pasted once, used once, never written to
# .env or disk.
#
# Args: DIR WEB_PORT
_homebox_offer_entity_type_fix() {
local DIR="$1" WEB_PORT="$2"
local LOCAL_URL="http://localhost:${WEB_PORT}"
echo ""
local _hit_bug=""
prompt_yn " Hit \"Please select an entity type\" with an empty type list when creating a Location/Item? (y/n):" "n" _hit_bug
[[ "$_hit_bug" =~ ^[Yy]$ ]] || return 0
echo ""
log_info "That's a known upstream Homebox bug (sysadminsmedia/homebox#1593) — this"
log_info "collection's default entity types were never seeded. Fixing it needs an"
log_info "API token from an account that's already registered:"
echo " 1. Log into Homebox in your browser (register first if you haven't)."
echo " 2. Open your profile menu -> Create API Token."
echo " 3. Paste it below — used once right now, never saved to disk."
echo ""
local HB_TOKEN=""
prompt_text " Homebox API token:" "" HB_TOKEN
if [ -z "$HB_TOKEN" ]; then
log_info "Skipped. Re-run 'sudo ./setup.sh homebox' (choose update) once you have a token."
return 0
fi
local VERIFY_CODE
VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $HB_TOKEN" "$LOCAL_URL/api/v1/users/self" 2>/dev/null)"
if [ "$VERIFY_CODE" != "200" ]; then
log_warning "Token didn't validate (HTTP $VERIFY_CODE) — skipping. Generate a fresh one and re-run."
return 0
fi
local EXISTING_TYPES
EXISTING_TYPES="$(curl -s -H "Authorization: Bearer $HB_TOKEN" "$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
if echo "$EXISTING_TYPES" | grep -q '"isLocation":[[:space:]]*true'; then
log_success "This collection already has a location-type entity type — nothing to fix."
return 0
fi
local _created=0 _code
_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $HB_TOKEN" \
-H "Content-Type: application/json" -d '{"name":"Location","isLocation":true}' \
"$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
[[ "$_code" == 20* ]] && _created=$((_created + 1))
_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $HB_TOKEN" \
-H "Content-Type: application/json" -d '{"name":"Item","isLocation":false}' \
"$LOCAL_URL/api/v1/entity-types" 2>/dev/null)"
[[ "$_code" == 20* ]] && _created=$((_created + 1))
if [ "$_created" -eq 2 ]; then
log_success "Created the missing 'Location' and 'Item' entity types — the Create dialog's type dropdown should be populated now."
else
log_warning "Something didn't go through cleanly — check the type dropdown in Homebox and retry if it's still empty."
fi
echo ""
log_info "This only fixes the ONE collection your token's account belongs to. Homebox"
log_info "has no documented way to switch a token between collections, so if you use"
log_info "more than one collection, log in as a member of each other one, generate a"
log_info "token there, and re-run this step for it too."
}
install_homebox() {
require_docker || return 1
log_info "Installing Homebox..."
@@ -381,6 +454,14 @@ install_homebox() {
|| log_warning "Refresh failed — check: docker compose -f $HB_DIR/docker-compose.yml logs"
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
_homebox_offer_disable_local_login "$HB_DIR"
# WEB_PORT isn't persisted anywhere but the compose
# file itself — re-derive it here the same way
# services/immich.sh does for its own update-path
# offer, rather than assuming the pre-scan default.
local _EXISTING_PORT
_EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:7745)' "$HB_DIR/docker-compose.yml" 2>/dev/null | head -1)"
[ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT"
_homebox_offer_entity_type_fix "$HB_DIR" "$WEB_PORT"
return 0
;;
cancel)
@@ -475,6 +556,7 @@ HB_ENV
configure_caddy_for_service "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:7745" "homebox${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
_homebox_offer_authelia_oidc "$HB_DIR" "$CONTAINER"
_homebox_offer_entity_type_fix "$HB_DIR" "$WEB_PORT"
write_readme "$HB_DIR" << MD
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+108
View File
@@ -0,0 +1,108 @@
## Android push notifications inconsistent after a migration (e.g. from PikaPods)
Symptom: "Enable Push Notifications" is on in System Console, but only some
Android users actually get background push notifications — one user gets
them reliably, others on the same server don't. Since this is per-device
rather than server-wide, work through these in order; the first that
reproduces the symptom is almost always the actual cause.
### 1. Rule out server → push-proxy connectivity first (quick, and if this is
broken it explains ALL users failing, not just some)
The mobile app talks to Google's FCM directly for the device token, but it's
*your* Mattermost server that calls out to the push relay (default
`https://push.mattermost.com`, System Console → Environment → Push
Notification Server) every time it needs to fan out a push. Confirm the new
VPS can actually reach it — a fresh box's outbound rules, or a NAT/firewall
inherited from the migration, can block this silently:
```bash
docker exec mattermost curl -Is https://push.mattermost.com | head -1
```
Then check the server's own logs for push attempts/failures:
```bash
docker compose logs mattermost | grep -i push
```
If specific users' pushes error out while others succeed, that already rules
out a global connectivity/config problem and points at something per-account
(section 2) or per-device (section 3).
### 2. Stale device registration carried over by the migration
A SQL dump import (`migrate-from-pikapods.sh` or any other DB restore) brings
the `Sessions` table with it — including each user's `DeviceId`, the
FCM token that was registered against the *old* server. That registration
only gets refreshed on a real login, not by the app quietly staying open:
a session that survived the move keeps working perfectly for live chat
(the websocket connection has nothing to do with push registration) while
its background push silently stops working, because the token behind it may
now be stale.
This matches "one user is always fine, everyone else isn't" almost exactly —
the working user is typically the one who happened to log out/in (or
reinstalled the app) since the migration, refreshing their `DeviceId`, while
everyone else's session rode through the import unchanged.
**Fix:** have affected users fully log out of the Mattermost Android app
(not just background it — Menu → Log Out) and log back in. This forces a
fresh device-token registration against the current server.
### 3. Android OEM battery optimization (the most common purely-device-side cause)
Xiaomi/MIUI, Huawei, Samsung, OnePlus, and Oppo/Vivo all ship aggressive
battery managers that kill background apps and their FCM listeners by
default — independent of anything about the server. This is the single most
common reason some Android phones on the exact same server get pushes and
others don't, migration or no migration. Have affected users check, per
device:
- **Settings → Apps → Mattermost → Battery** → set to "Unrestricted" / "No
restrictions" / disable "Battery Saver" for the app (menu wording varies
by OEM/Android version).
- **Notification permission itself** is still granted — Android 13+ requires
an explicit runtime permission that can get silently revoked (e.g. after
an OS update), separate from the app's own in-app notification settings.
- Some OEMs (Xiaomi especially) also gate this behind a separate
"Autostart" toggle for the app.
### 4. Push notification content setting, if section 1 and 2 don't explain it
System Console → Environment → Push Notification Server → **Push
Notification Contents**. If set to anything other than "Send full message
contents", the client has to phone the server's own `SiteURL` back for the
real content after getting the push shell — so if the *new* domain isn't
reliably reachable from a given user's network (split-horizon DNS, a mobile
carrier blocking something, a half-finished Caddy/DNS cutover for the new
VPS), that user can receive the push notification itself but never see
real content, or see it inconsistently. Temporarily switching to "Send full
message contents" removes this variable while narrowing down the cause.
### 5. A changed FQDN specifically — DNS propagation and cert readiness
If the migration also moved the server to a new domain (not just a new box
under the same domain), that alone can produce exactly this
some-users-fine/some-users-not pattern for a few days after cutover, on top
of section 2 and 4 above:
- **DNS propagation lags per device.** Different users' resolvers (ISP DNS,
carrier DNS on cellular, cached records with old TTLs) pick up the new
FQDN's IP at different times. A user on a fast public resolver sees it
immediately; someone on carrier DNS with a stale cache might not resolve
it correctly for hours. Combined with section 4 (anything but "full
message contents" requires a content-fetch call back to `SiteURL`), a
device with a stale answer for the new FQDN fails that step while others
succeed.
- **TLS certificate not fully issued/propagated yet** for the new FQDN
(Caddy/Let's Encrypt) causes the same content-fetch failure via cert
validation instead of DNS.
- **Confirm `MM_SERVICESETTINGS_SITEURL` in `.env` is actually the new FQDN**
— a value left over from before the domain change points every client's
content-fetch at the wrong place, consistently, not just intermittently.
- Users who never explicitly added the new server URL in the mobile app (a
kept redirect from the old domain let them keep working without
noticing) are still running on their old, stale device registration —
this is section 2's mechanism, just caused directly by the FQDN change
rather than by the DB import alone.
+805
View File
@@ -0,0 +1,805 @@
#!/bin/bash
# services/pressbooks.sh — Self-hosted Pressbooks: write/import books, drag-and-drop
# image placement, export to PDF/EPUB for professional or personal printing.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash pressbooks.sh
# (Docker must already be installed when run standalone)
#
# Pressbooks is a WordPress Multisite plugin/theme suite, not a normal
# WordPress plugin — its own docs are explicit that it "should be used with
# a fresh, multisite WordPress installation" and is "not for use on an
# existing blog." That means it can never be layered onto an existing
# services/wordpress.sh site: it gets its own dedicated WordPress core,
# database, and container here, converted to a Multisite network as part of
# this installer instead of a plain single-site install.
#
# Not following the multi-instance pattern documented in CLAUDE.md: that
# pattern exists for services that are inherently single-tenant per
# install. Pressbooks is the opposite — a single network already hosts any
# number of independent books (each its own site in the network, its own
# authors, its own theme), which is exactly the multi-tenancy the pattern
# gives other services. A second, fully separate Pressbooks *network* would
# only matter for something like two unrelated publishing organizations
# wanting entirely separate admin/user databases on one box — a much rarer
# need than "another book" — so it's left out of scope here.
#
# Chapters are written and images placed via WordPress's own block editor
# (Gutenberg) — dragging an image file into a chapter's content area drops
# an Image block at that position, and the block editor's own "Add Media"
# dialog also accepts drag-and-drop uploads. This is native WordPress
# behavior, not a Pressbooks feature, so it needs no extra plugin here.
#
# PDF export needs a rendering engine Pressbooks itself doesn't ship:
# - PrinceXML, installed on this container — free for non-commercial use
# (adds a small logo to page 1 of every PDF), full price for a
# commercial/watermark-free license. See install_pressbooks' Dockerfile
# generation below.
# - DocRaptor, PrinceXML as a paid SaaS API (DOCRAPTOR_API_KEY) — no local
# binary to maintain, but not free for real (non-watermarked) documents.
# - mPDF, Pressbooks' third documented option, is explicitly unmaintained
# upstream — not offered here.
# EPUB export needs no extra engine (Pressbooks generates it directly) and
# needs EPUBCheck's dependencies below. MOBI export was removed from
# Pressbooks entirely after Amazon discontinued KindleGen and stopped
# accepting MOBI on KDP (March 2025) — not offered here; see the generated
# README for the EPUB→MOBI-via-Calibre workaround for personal Kindle use.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_reinstall_mode() {
local _var="$1" _r
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
echo " Existing install detected. Choose:"
echo " u) Update — refresh plugin/theme/image, keep books and settings"
echo " f) Full reinstall — re-run every prompt from scratch"
echo " c) Cancel — leave everything as-is [default]"
read -r -p " Choice [u/f/c, Enter=cancel]: " _r
case "${_r,,}" in
u) eval "$_var='update'" ;;
f) eval "$_var='fresh'" ;;
*) eval "$_var='cancel'" ;;
esac
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
CADDY_SERVICE_CONFIGURED=false
CADDY_SERVICE_MODE=""
CADDY_SERVICE_DOMAIN=""
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
local _block_upstream="$_upstream"
[[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
${_extra}
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; CADDY_SERVICE_CONFIGURED=true; CADDY_SERVICE_MODE="local"; CADDY_SERVICE_DOMAIN="$_domain"; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
CADDY_SERVICE_CONFIGURED=true
CADDY_SERVICE_MODE="local"
CADDY_SERVICE_DOMAIN="$_domain"
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
fi
else
CADDY_SERVICE_CONFIGURED=true
CADDY_SERVICE_MODE="remote"
CADDY_SERVICE_DOMAIN="$_domain"
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service pressbooks utilities "Self-hosted Pressbooks — write/import books with drag-and-drop images, export to PDF/EPUB (Authelia SSO gate)" 8095
# Fetches the newest release of a pressbooks/<repo> GitHub project as an
# installable zip URL, for wp-cli's own "plugin install <url>"/"theme install
# <url>" (which download and unpack it itself — nothing here needs to know
# how to unzip a WordPress plugin). Prefers an actual release asset (the
# packaged, ready-to-install zip these projects publish, vendor/ dependencies
# included) and falls back to the tagged source archive GitHub always
# generates automatically if no asset is found — that fallback can be
# missing composer's vendor/ directory, so it's logged with a warning
# rather than silently swapped in.
_pressbooks_latest_zip_url() {
local _repo="$1" _api _url _tag
_api="$(curl -fsSL "https://api.github.com/repos/pressbooks/${_repo}/releases/latest" 2>/dev/null)"
_url="$(printf '%s' "$_api" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*\.zip"' | head -1 | grep -o 'https://[^"]*')"
if [ -z "$_url" ]; then
_tag="$(printf '%s' "$_api" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)"
if [ -n "$_tag" ]; then
_url="https://github.com/pressbooks/${_repo}/archive/refs/tags/${_tag}.zip"
log_warning "No packaged release asset found for ${_repo} — falling back to its" >&2
log_warning "tagged source archive, which can be missing composer's vendor/ directory." >&2
fi
fi
printf '%s' "$_url"
}
# A release zip's top-level directory sometimes carries a version suffix
# (especially the tagged-source-archive fallback above, e.g.
# "pressbooks-book-2.5.0/" instead of "pressbooks-book/") — WP-CLI's own
# "theme enable"/"plugin activate --network" need the directory to be named
# exactly the plugin/theme slug to find it at all. Renames it into place if
# a mismatch is found; a no-op if the zip already unpacked to the right name.
_pressbooks_normalize_slug() {
local _html_dir="$1" _kind="$2" _slug="$3"
docker run --rm -v "${_html_dir}:/var/www/html" alpine sh -c "
cd /var/www/html/wp-content/${_kind} 2>/dev/null || exit 0
[ -d '${_slug}' ] && exit 0
d=\$(ls -d ${_slug}-* 2>/dev/null | head -1)
[ -n \"\$d\" ] && mv \"\$d\" '${_slug}'
exit 0
" >/dev/null 2>&1
}
# Installs/refreshes the Pressbooks plugin and its three companion themes
# (McLuhan/pressbooks-book — the default book theme; Aldine — the default
# root theme; Publisher — the default theme for the network's own landing
# site) network-wide, and activates Publisher on the root site. Shared by
# the fresh-install path and the "update" rerun path (CLAUDE.md's
# non-destructive-update convention: this only ever touches plugin/theme
# code, never wp-config.php, .env, or any book's own content/DB rows).
_pressbooks_install_plugins_and_themes() {
local _dir="$1" _net="$2" _port="$3"
# "wp" is spelled out explicitly rather than relying on the wordpress:cli
# entrypoint's own "wp help $1 && set -- wp $@" auto-detection — that
# probe itself runs through wp-cli's bootstrap, so anything that breaks
# the bootstrap (a bad wp-config.php, a missing bind mount) makes the
# probe fail *silently* and falls through to exec-ing the raw
# subcommand as if it were a binary ("core: not found") instead of
# surfacing the real error.
_pb_wpcli() { docker run --rm --network "$_net" -v "${_dir}/html:/var/www/html" --env-file "${_dir}/.env" wordpress:cli wp "$@"; }
local _plugin_url _book_url _aldine_url _publisher_url
_plugin_url="$(_pressbooks_latest_zip_url pressbooks)"
_book_url="$(_pressbooks_latest_zip_url pressbooks-book)"
_aldine_url="$(_pressbooks_latest_zip_url pressbooks-aldine)"
_publisher_url="$(_pressbooks_latest_zip_url pressbooks-publisher)"
if [ -z "$_plugin_url" ]; then
log_error "Couldn't determine a Pressbooks download URL from GitHub (API unreachable or rate-limited)."
log_error "Install by hand instead: download a release zip from"
log_error " https://github.com/pressbooks/pressbooks/releases"
log_error "then, in Network Admin -> Plugins -> Add New -> Upload Plugin, upload it and Network Activate."
return 1
fi
log_info "Installing Pressbooks plugin..."
_pb_wpcli plugin install "$_plugin_url" --force || log_warning "Pressbooks plugin install reported an error — see docker compose logs."
_pressbooks_normalize_slug "${_dir}/html" plugins pressbooks
_pb_wpcli plugin activate pressbooks --network || log_warning "Network-activating Pressbooks failed — do it by hand in Network Admin -> Plugins."
log_info "Installing Pressbooks themes (McLuhan, Aldine, Publisher)..."
for _pair in "pressbooks-book:$_book_url" "pressbooks-aldine:$_aldine_url" "pressbooks-publisher:$_publisher_url"; do
local _slug="${_pair%%:*}" _url="${_pair#*:}"
[ -z "$_url" ] && { log_warning "Couldn't determine a download URL for theme $_slug — skipping."; continue; }
_pb_wpcli theme install "$_url" --force || log_warning "Theme $_slug install reported an error."
_pressbooks_normalize_slug "${_dir}/html" themes "$_slug"
_pb_wpcli theme enable "$_slug" || log_warning "Network-enabling $_slug failed — do it by hand in Network Admin -> Themes."
done
_pb_wpcli theme activate pressbooks-publisher --url="http://localhost:${_port}" \
|| log_warning "Couldn't set Publisher as the network's own landing-site theme — set it by hand in Appearance -> Themes."
}
install_pressbooks() {
require_docker || return 1
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ PRESSBOOKS │"
echo "│ Self-hosted book platform — write/import chapters with │"
echo "│ drag-and-drop images, export to PDF (print) and EPUB (ebook) │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
local DIR="$DOCKER_DIR/pressbooks"
local CONTAINER="pressbooks"
local DB_CONTAINER="pressbooks-db"
local WP_NET="pressbooks_net"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR — a dedicated WordPress Multisite install (never an"
echo "[DRY-RUN] existing site — Pressbooks requires a fresh multisite network)"
echo "[DRY-RUN] Would build a custom image on wordpress:php8.3-apache: mod_rewrite +"
echo "[DRY-RUN] AllowOverride All (multisite needs working .htaccess rewrites),"
echo "[DRY-RUN] Ghostscript/ImageMagick/poppler-utils/libxml2-utils (cover generator +"
echo "[DRY-RUN] EPUB validation), and the ImageMagick PDF-coder policy fix Debian ships"
echo "[DRY-RUN] disabled by default"
echo "[DRY-RUN] Would prompt for a PDF export engine: PrinceXML (installed on this"
echo "[DRY-RUN] container, free for non-commercial use) and/or DocRaptor (paid SaaS API key)"
echo "[DRY-RUN] Would auto-scan for a free host port (8095 default) and dedicated MariaDB"
echo "[DRY-RUN] Would run wp-cli non-interactively: core install, convert to Multisite"
echo "[DRY-RUN] (subdirectory network), install+network-activate the Pressbooks plugin"
echo "[DRY-RUN] and its three themes"
echo "[DRY-RUN] Would offer a Caddy reverse proxy gated by Authelia SSO, and to start it"
return 0
fi
# ── Existing install? Offer update-in-place ──────────────────────────────
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing Pressbooks' base image, cover-generator packages, and the"
log_info "Pressbooks plugin/themes only — books, domain, and credentials are left"
log_info "exactly as they are."
( cd "$DIR" && docker compose build --pull && docker compose up -d )
local _WP_PORT
_WP_PORT="$(grep '^WEB_PORT=' "$DIR/.env" | cut -d= -f2-)"
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "${_WP_PORT:-8095}"
log_success "Pressbooks refreshed"
return 0
;;
cancel)
log_info "Leaving the existing Pressbooks install as-is."
return 0
;;
fresh) ;;
esac
fi
# ── Network title + admin account ────────────────────────────────────────
local PB_TITLE="" PB_ADMIN_USER="" PB_ADMIN_EMAIL=""
prompt_text "Book network title (shown on the landing site):" "My Book Library" PB_TITLE
prompt_text "Admin username:" "admin" PB_ADMIN_USER
prompt_text "Admin email:" "" PB_ADMIN_EMAIL
local PB_ADMIN_PASS=""
[ -f "$DIR/.env" ] && PB_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)"
[ -n "$PB_ADMIN_PASS" ] || PB_ADMIN_PASS="$(generate_password 16)"
# ── PDF export engine ─────────────────────────────────────────────────────
echo ""
echo " PDF export needs a rendering engine Pressbooks itself doesn't ship."
local INSTALL_PRINCE="" PRINCE_LICENSE_PATH="" USE_DOCRAPTOR="" DOCRAPTOR_KEY=""
prompt_yn "Install PrinceXML for PDF export? Free for personal use, adds a small logo unless licensed (y/n):" "y" INSTALL_PRINCE
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
prompt_text " Already have a paid PrinceXML license file (removes the logo)? Path, or blank to skip:" "" PRINCE_LICENSE_PATH
if [ -n "$PRINCE_LICENSE_PATH" ] && [ ! -f "$PRINCE_LICENSE_PATH" ]; then
log_warning " $PRINCE_LICENSE_PATH not found — continuing with the free non-commercial version."
PRINCE_LICENSE_PATH=""
fi
fi
prompt_yn "Also configure DocRaptor (SaaS alternative — needs your own API key, paid past a small free quota)? (y/n):" "n" USE_DOCRAPTOR
if [[ "$USE_DOCRAPTOR" =~ ^[Yy]$ ]]; then
prompt_text " DocRaptor API key (from https://docraptor.com/documentation/api):" "" DOCRAPTOR_KEY
fi
if [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ]; then
log_warning "No PDF engine configured — Pressbooks' PDF export will fail until PrinceXML"
log_warning "or DocRaptor is set up (re-run this installer to add one later)."
fi
# ── Free host port ────────────────────────────────────────────────────────
local WEB_PORT=8095
find_free_port WEB_PORT "$WEB_PORT"
mkdir -p "$DIR/html" "$DIR/db" "$DIR/uploads-ini.d"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
local TZ_VAL="${SITE_TZ:-UTC}"
# Book covers, full-book PDF/EPUB exports, and large chapter-image
# imports all run well past stock PHP limits — sized generously up front
# rather than waiting for a first export to hit a wall.
cat > uploads-ini.d/uploads.ini << 'PHPINI'
file_uploads = On
memory_limit = 512M
upload_max_filesize = 128M
post_max_size = 128M
max_execution_time = 600
max_input_time = 600
PHPINI
# WordPress core's own is_ssl() only looks at $_SERVER['HTTPS'], never
# X-Forwarded-Proto — behind Caddy (which terminates TLS and proxies
# plain HTTP to this container) that reads as "never HTTPS," sending
# wp-admin into a login/redirect loop the moment Caddy is wired up.
#
# This lives in a must-use plugin (wp-content/mu-plugins/, autoloaded by
# WordPress on every request, no activation needed) rather than in
# wp-config.php via WORDPRESS_CONFIG_EXTRA — two real, confirmed-live
# problems with the wp-config.php route, in order of discovery:
# 1. Compose interpolates $VAR-looking tokens found INSIDE .env file
# values too, not just inside docker-compose.yml — a raw $_SERVER
# sitting in .env got silently blanked to a bare "_SERVER" before
# the container ever saw it.
# 2. Routing it through a bind-mounted file and a wp-config.php
# `require` line (this repo's first fix for #1) traded that bug for
# a worse one: wp-cli's Runner does its own restricted, line-level
# parsing of wp-config.php to pull out bootstrap constants without
# a full WordPress load, and it can't handle anything past a plain
# define(...) statement — an if(){ require ...; } line made *every*
# wp-cli command in this script fail with a cryptic
# "PHP Parse error ... eval()'d code ... unexpected end of file".
# mu-plugins load through WordPress's normal plugin bootstrap, not
# wp-cli's special wp-config.php pre-parser, so this sidesteps both
# issues entirely — nothing here ever touches wp-config.php or .env.
mkdir -p html/wp-content/mu-plugins
cat > html/wp-content/mu-plugins/pressbooks-extra-config.php << 'PHPEXTRA'
<?php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
PHPEXTRA
[[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo "define('PB_PRINCE_COMMAND', '/usr/local/bin/prince');" >> html/wp-content/mu-plugins/pressbooks-extra-config.php
[ -n "$DOCRAPTOR_KEY" ] && echo "define('DOCRAPTOR_API_KEY', '$DOCRAPTOR_KEY');" >> html/wp-content/mu-plugins/pressbooks-extra-config.php
# Prince license file, if provided, is bind-mounted rather than baked
# into the image — keeps a personal/purchased license out of the image
# layer, and survives an image rebuild on the "update" path untouched.
local PRINCE_LICENSE_VOLUME=""
if [ -n "$PRINCE_LICENSE_PATH" ]; then
cp "$PRINCE_LICENSE_PATH" "$DIR/prince-license.dat"
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/prince-license.dat"
chmod 600 "$DIR/prince-license.dat"
PRINCE_LICENSE_VOLUME=" - ./prince-license.dat:/usr/local/lib/prince/license/license.dat:ro
"
fi
# ── Dockerfile ────────────────────────────────────────────────────────────
local _PRINCE_DOCKERFILE_BLOCK=""
if [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]]; then
_PRINCE_DOCKERFILE_BLOCK='
# PrinceXML — the PDF rendering engine Pressbooks shells out to for PDF
# export. Free for non-commercial use (small logo on page 1 of every PDF; a
# purchased license.dat, bind-mounted by docker-compose.yml, removes it).
# Uses the "linux-generic" tarball rather than a distro-pinned .deb/.rpm so
# this keeps working if wordpress:php8.3-apache'"'"'s underlying Debian release
# moves on, and resolves the current major version + exact filename at
# build time instead of hardcoding one that will eventually go stale.
RUN set -eux; \
ARCH="$(uname -m)"; \
MAJOR="$(curl -fsSL https://www.princexml.com/download/ | grep -oE "/download/[0-9]+/" | grep -oE "[0-9]+" | sort -n | tail -1)"; \
TARBALL_PATH="$(curl -fsSL "https://www.princexml.com/download/${MAJOR}/" | grep -oE "/download/prince-[0-9.]+-linux-generic-${ARCH}\.tar\.gz" | head -1)"; \
curl -fsSL "https://www.princexml.com${TARBALL_PATH}" -o /tmp/prince.tar.gz; \
mkdir -p /tmp/prince && tar -xzf /tmp/prince.tar.gz -C /tmp/prince --strip-components=1; \
printf "\n" | /tmp/prince/install.sh; \
rm -rf /tmp/prince /tmp/prince.tar.gz
'
fi
backup_if_exists Dockerfile
cat > Dockerfile << DOCKERFILE
FROM wordpress:php8.3-apache
# Multisite's subdirectory rewrite rules live in .htaccess — the base
# php-apache image ships mod_rewrite disabled and AllowOverride None, so
# .htaccess is silently ignored (pretty URLs 404, book pages don't route)
# without this.
RUN a2enmod rewrite \\
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
# Pressbooks' cover generator shells out to Ghostscript/ImageMagick and
# poppler-utils (pdftoppm/pdfinfo) to rasterize book covers; libxml2-utils
# (xmllint) backs EPUB/HTMLBook validation. curl/ca-certificates are needed
# by the PrinceXML install step below, when enabled.
RUN apt-get update \\
&& apt-get install -y --no-install-recommends \\
ghostscript imagemagick poppler-utils libxml2-utils curl ca-certificates \\
&& rm -rf /var/lib/apt/lists/*
# Debian's ImageMagick ships a security policy (a CVE-2016-3714 mitigation)
# that blocks the PDF/PS/EPS coders by default. Without this, ImageMagick
# refuses to rasterize the PDF Ghostscript hands it for a cover thumbnail —
# fails with "not authorized \`PDF'" rather than producing an image.
RUN for f in /etc/ImageMagick-6/policy.xml /etc/ImageMagick-7/policy.xml; do \\
[ -f "\$f" ] && sed -i -E 's/rights="none" pattern="(PDF|PS|EPS)"/rights="read|write" pattern="\\1"/' "\$f"; \\
done; true
${_PRINCE_DOCKERFILE_BLOCK}
DOCKERFILE
# ── Caddy network wiring ──────────────────────────────────────────────────
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_LINE="" _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_LINE=" - caddy_net
"
_CADDY_NET_SECTION="
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
# ── docker-compose.yml ────────────────────────────────────────────────────
backup_if_exists docker-compose.yml
cat > docker-compose.yml << PBCOMPOSE
name: pressbooks
services:
pressbooks:
build: .
container_name: $CONTAINER
hostname: $CONTAINER
restart: unless-stopped
env_file: .env
depends_on:
- db
volumes:
- ./html:/var/www/html
- ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
${PRINCE_LICENSE_VOLUME} ports:
- "${WEB_PORT}:80"
networks:
- default
${_CADDY_NET_LINE}
db:
image: mariadb:11
container_name: $DB_CONTAINER
hostname: $DB_CONTAINER
restart: unless-stopped
env_file: .env
volumes:
- ./db:/var/lib/mysql
networks:
- default
networks:
default:
name: $WP_NET
${_CADDY_NET_SECTION}
PBCOMPOSE
# ── .env ──────────────────────────────────────────────────────────────────
local WP_DB_PASS="" WP_DB_ROOT_PASS=""
[ -f ".env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' .env | cut -d= -f2-)"
[ -f ".env" ] && WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' .env | cut -d= -f2-)"
[ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)"
[ -n "$WP_DB_ROOT_PASS" ] || WP_DB_ROOT_PASS="$(generate_password 32)"
backup_if_exists .env
cat > .env << PBENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
WEB_PORT=$WEB_PORT
# Dedicated MariaDB for this network alone.
MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS
MYSQL_DATABASE=pressbooks
MYSQL_USER=pressbooks
MYSQL_PASSWORD=$WP_DB_PASS
WORDPRESS_DB_HOST=$DB_CONTAINER
WORDPRESS_DB_NAME=pressbooks
WORDPRESS_DB_USER=pressbooks
WORDPRESS_DB_PASSWORD=$WP_DB_PASS
# Only consulted by wp-cli during initial setup below, not read by the
# wordpress:apache image itself.
WP_SITE_TITLE=$PB_TITLE
WP_ADMIN_USER=$PB_ADMIN_USER
WP_ADMIN_PASSWORD=$PB_ADMIN_PASS
WP_ADMIN_EMAIL=$PB_ADMIN_EMAIL
PBENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
log_success "Pressbooks configured at $DIR (port $WEB_PORT)"
log_info "Building image (first build downloads PrinceXML and cover-generator packages — can take a few minutes)..."
if ! docker compose build; then
log_error "Image build failed — check the output above."
return 1
fi
if ! docker compose up -d; then
log_error "Failed to start — check: docker compose logs"
return 1
fi
log_info "Waiting for WordPress to come up..."
local _tries=0
until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do
sleep 1; _tries=$((_tries + 1))
done
# "wp" spelled out explicitly — see _pb_wpcli's comment above for why.
_wpcli() { docker run --rm --network "$WP_NET" -v "$DIR/html:/var/www/html" --env-file "$DIR/.env" wordpress:cli wp "$@"; }
log_info "Running wp-cli core install..."
if ! _wpcli core install \
--url="http://localhost:${WEB_PORT}" \
--title="$PB_TITLE" \
--admin_user="$PB_ADMIN_USER" \
--admin_password="$PB_ADMIN_PASS" \
--admin_email="$PB_ADMIN_EMAIL" \
--skip-email; then
log_error "wp-cli core install failed — WordPress may not have been ready yet. Retry manually:"
log_error " docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\"
log_error " --env-file $DIR/.env wordpress:cli wp core install ..."
return 1
fi
# Multisite refuses to activate with the "Plain" (query-string) permalink
# structure — pretty permalinks are a hard prerequisite, not optional.
log_info "Setting pretty permalinks and converting to a Multisite network..."
_wpcli rewrite structure '/%postname%/' --hard
_wpcli core multisite-convert --title="$PB_TITLE"
# multisite-convert doesn't rewrite .htaccess itself on Apache — without
# this, every site but the root 404s.
_wpcli rewrite flush --hard
_pressbooks_install_plugins_and_themes "$DIR" "$WP_NET" "$WEB_PORT"
# ── Authelia SSO gate ─────────────────────────────────────────────────────
# Pressbooks/WordPress has its own login screen, but no native
# OIDC/reverse-proxy-auth support the way gitea/mealie do (a third-party
# plugin could add one, the same "bigger lift" caveat CLAUDE.md notes for
# Jellyfin/Home Assistant) — so this is the same forward_auth gate used
# for services with no built-in auth at all: Authelia guards the front
# door, WordPress's own login is still a second gate behind it.
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local USE_AUTHELIA=""
prompt_yn "Protect Pressbooks with Authelia SSO? (y/n):" "y" USE_AUTHELIA
[[ "$USE_AUTHELIA" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "Pressbooks" "${CONTAINER}:80" "books" "$EXTRA_BLOCK"
# Reconcile the domain WordPress/Multisite think they're on: core install
# ran against http://localhost:$WEB_PORT since the final domain isn't
# known until the Caddy prompt above. Two passes — the full scheme+host
# string first (catches siteurl/home, stored with "http://"), then the
# bare host (catches wp_site.domain/wp_blogs.domain, stored without a
# scheme) — doing it in the other order would leave siteurl/home on
# "http://" instead of "https://" once Caddy is terminating TLS.
if [ "$CADDY_SERVICE_CONFIGURED" = true ] && [ -n "$CADDY_SERVICE_DOMAIN" ]; then
_wpcli search-replace "http://localhost:${WEB_PORT}" "https://${CADDY_SERVICE_DOMAIN}" --network --all-tables --report-changed-only
_wpcli search-replace "localhost:${WEB_PORT}" "$CADDY_SERVICE_DOMAIN" --network --all-tables --report-changed-only
log_success "Updated the network's URLs to https://$CADDY_SERVICE_DOMAIN"
fi
declare -F _authelia_scope_access >/dev/null 2>&1 && [ "$CADDY_SERVICE_CONFIGURED" = true ] \
&& _authelia_scope_access "pressbooks" "$CADDY_SERVICE_DOMAIN"
local PB_ACCESS_URL="http://localhost:${WEB_PORT}"
[ "$CADDY_SERVICE_CONFIGURED" = true ] && PB_ACCESS_URL="https://$CADDY_SERVICE_DOMAIN"
write_readme "$DIR" << MD
# Pressbooks
Self-hosted book platform on a dedicated WordPress Multisite network (its
own container/database — never shares an install with \`services/wordpress.sh\`,
since Pressbooks requires a fresh multisite network of its own).
- Network admin: ${PB_ACCESS_URL}/wp-admin/network/
- Admin user: \`$PB_ADMIN_USER\`
- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\`
- Book files: \`html/\`
- Database files: \`db/\`
- PHP limits: \`uploads-ini.d/uploads.ini\` (512M memory, 128M uploads, 600s
execution time — a full-book PDF export can take a while)
## Creating a book
My Sites -> Network Admin -> Sites -> Add New creates a new book (its own
site in the network). Each book gets its own theme, its own chapters, and
its own front/back matter, picked from the Pressbooks admin bar once inside it.
## Writing and placing images
Chapters are written in WordPress's own block editor. Type directly into a
chapter; to place an image, either drag an image file straight into the
content area to drop it in as an Image block exactly where you dropped it,
or use the editor's own Add Media button, which also accepts drag-and-drop
in its upload dialog. Cover images are uploaded the same way from a book's
own Book Info screen.
## Exporting
Export options live under each book's own Export screen.
- **EPUB** — generated directly by Pressbooks, no extra engine needed.
- **PDF** — needs the rendering engine chosen at install time:
$( [[ "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && echo " - PrinceXML is installed on this container.$( [ -n "$PRINCE_LICENSE_PATH" ] && echo " A license file is installed — no watermark." || echo " Free non-commercial version — adds a small logo to page 1 of every PDF; re-run this installer with a purchased license.dat to remove it." )" )
$( [ -n "$DOCRAPTOR_KEY" ] && echo " - DocRaptor is configured as an alternative/fallback (uses your own API key — real documents count against your DocRaptor plan; DocRaptor's own \`test\` mode produces unlimited watermarked previews for free)." )
$( [[ ! "$INSTALL_PRINCE" =~ ^[Yy]$ ]] && [ -z "$DOCRAPTOR_KEY" ] && echo " - Not configured yet — re-run this installer (Update or Full reinstall) to add PrinceXML and/or DocRaptor." )
- **MOBI/Kindle** — Pressbooks removed MOBI export after Amazon discontinued
KindleGen and stopped accepting MOBI on KDP (March 2025). For a personal
Kindle copy, export EPUB and convert it with Calibre — this repo's own
\`calibre-web\` service can do that conversion if you don't already have
Calibre elsewhere.
## Manage
\`\`\`bash
cd $DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose build --pull && docker compose up -d # refresh base image + packages
\`\`\`
Or re-run \`sudo ./setup.sh pressbooks\` and choose Update, which also
refreshes the Pressbooks plugin/themes to their latest release.
## wp-cli
\`\`\`bash
docker run --rm --network $WP_NET -v $DIR/html:/var/www/html \\
--env-file $DIR/.env wordpress:cli wp <command>
\`\`\`
## Backup
\`services/backup.sh\` (Kopia) already covers this directory automatically —
generic for every \`~/docker/*\` directory with a \`docker-compose.yml\`, so
both \`html/\` (every book's content and media) and \`db/\` are captured
together on every run with no per-service setup needed.
MD
echo ""
echo " Access at: $PB_ACCESS_URL"
echo " Network admin: ${PB_ACCESS_URL}/wp-admin/network/"
echo " Admin user: $PB_ADMIN_USER"
echo " Admin pass: $PB_ADMIN_PASS"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_pressbooks
+365
View File
@@ -0,0 +1,365 @@
#!/bin/bash
# services/samba.sh — Samba (SMB/CIFS) file sharing: shares, users, passwords.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash samba.sh
#
# Samba is a SYSTEM install (apt package + native smbd/nmbd services), NOT a
# docker-compose service — same shape as services/crowdsec.sh. There is no
# ~/docker/samba compose stack; we only create a docs-only folder there with
# a README pointing at the real config under /etc/samba/smb.conf. This is
# the SERVER side — for mounting an existing remote Samba share instead, see
# services/vpn-data-mount.sh (deliberately the opposite: reads an existing
# smb.conf over SSH, never installs Samba, never creates or resets a share
# password).
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
}
ensure_ufw_enabled() {
command -v ufw &>/dev/null || return 0
ufw status 2>/dev/null | grep -q "Status: active" && return 0
local _ssh_port
_ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null \
| tail -1 | awk '{print $2}')"
_ssh_port="${_ssh_port:-22}"
ufw allow "${_ssh_port}/tcp" comment 'SSH' >/dev/null 2>&1
ufw --force enable >/dev/null 2>&1
log_success "UFW enabled (SSH on port ${_ssh_port} allowed first, so this won't lock you out)."
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service samba utilities "Samba file sharing (SMB/CIFS) — shares, users, passwords"
install_samba() {
local SMB_CONF="/etc/samba/smb.conf"
local DOCS_DIR="$DOCKER_DIR/samba"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install samba (smbd/nmbd) if not already present"
echo "[DRY-RUN] Would show any shares this installer already manages"
echo "[DRY-RUN] Would prompt to add one or more shares (path, guest-or-authenticated, users)"
echo "[DRY-RUN] Would create a system Linux account + Samba password for any new user"
echo "[DRY-RUN] Would append share stanzas to $SMB_CONF, validate with testparm, restart smbd/nmbd"
echo "[DRY-RUN] Would open UFW for SMB (137/138 udp, 139/445 tcp) — scoped to the LAN by default"
echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs only — Samba itself runs natively, not in Docker)"
return 0
fi
if ! command -v smbd &>/dev/null; then
log_info "Installing Samba..."
apt-get update -y
apt-get install -y samba || { log_error "Samba install failed"; return 1; }
log_success "Samba installed"
else
log_success "Samba already installed"
fi
backup_if_exists "$SMB_CONF"
if grep -q '^# ubuntu-post-install:share:' "$SMB_CONF" 2>/dev/null; then
echo ""
log_info "Shares already managed by this installer:"
grep '^# ubuntu-post-install:share:' "$SMB_CONF" | sed 's/^# ubuntu-post-install:share:/ - /'
fi
echo ""
local _added_any=false
while true; do
local ADD_SHARE=""
prompt_yn "Add a Samba share now? (y/n):" "y" ADD_SHARE
[[ "$ADD_SHARE" =~ ^[Yy]$ ]] || break
_samba_add_share "$SMB_CONF" && _added_any=true
echo ""
done
if [ "$_added_any" = true ]; then
log_info "Validating smb.conf..."
if testparm -s "$SMB_CONF" &>/dev/null; then
systemctl restart smbd 2>/dev/null
systemctl restart nmbd 2>/dev/null # NetBIOS name resolution — some Samba packages split this out
log_success "smbd/nmbd restarted with the new configuration"
else
log_error "testparm reports smb.conf is invalid — NOT restarting smbd/nmbd."
log_error "Check manually: sudo testparm -s $SMB_CONF"
return 1
fi
else
log_info "No shares added this run."
fi
_samba_configure_firewall
mkdir -p "$DOCS_DIR"
ensure_docker_dir_ownership "$DOCS_DIR"
write_readme "$DOCS_DIR" << MD
# Samba
Samba runs natively on this box (not in Docker) — the real config is
\`/etc/samba/smb.conf\`, managed by \`systemctl\`. This folder just holds this
README; there's no compose stack here.
## Manage
\`\`\`bash
sudo testparm -s # validate smb.conf before restarting
sudo systemctl restart smbd nmbd
sudo systemctl status smbd
\`\`\`
## Shares
Re-run \`sudo ./setup.sh samba\` (or \`sudo bash services/samba.sh\` standalone)
to add another share or another user — existing shares/users are left alone.
Each share this installer wrote is marked in smb.conf with a
\`# ubuntu-post-install:share:<name>\` comment right above its \`[<name>]\`
stanza, so you can find (or hand-edit / remove) them later.
## Users
Samba users need BOTH a Linux account and a separate Samba password
(\`smbpasswd\`) — they are not the same credential. This installer creates a
system account (\`useradd --system --no-create-home\`, no shell login) for
any username that doesn't already exist as a Linux user, adds it to the
\`sambashare\` group, and sets its Samba password with \`smbpasswd\`.
\`\`\`bash
sudo smbpasswd <username> # change an existing user's Samba password
sudo pdbedit -L # list all Samba users
sudo smbpasswd -x <username> # remove a user from Samba (leaves the Linux account alone)
\`\`\`
## Connecting
- Windows: \`\\\\<server-ip>\\<share-name>\`
- macOS Finder: Go -> Connect to Server -> \`smb://<server-ip>/<share-name>\`
- Linux: \`smbclient //<server-ip>/<share-name> -U <username>\` or mount with
\`mount.cifs\` / \`cifs-utils\` (already installed by \`services/base.sh\`).
## Firewall
SMB (137/138 UDP, 139/445 TCP) should almost never be exposed to the public
internet — this installer scopes the UFW rule to your LAN subnet by default.
Check what's currently allowed with \`sudo ufw status | grep -E '13[7-9]|445'\`.
MD
log_success "Samba configured. Re-run 'sudo ./setup.sh samba' any time to add another share or user."
}
# Appends one [share] stanza to smb.conf. Returns non-zero (and adds nothing)
# on a blank/duplicate name so the caller's "did we actually add one" tracking
# stays accurate.
_samba_add_share() {
local _conf="$1"
local NAME="" SHARE_PATH="" GUEST=""
prompt_text " Share name (letters/numbers/hyphens/underscores, e.g. media):" "" NAME
NAME="$(echo "$NAME" | tr -cd 'A-Za-z0-9_-')"
if [[ -z "$NAME" ]]; then
log_warning "Share name required — skipping."
return 1
fi
if grep -q "^\[$NAME\]\$" "$_conf" 2>/dev/null; then
log_warning "A share named [$NAME] already exists in smb.conf — skipping."
log_warning "Edit $_conf by hand to change it, or pick a different name."
return 1
fi
local DEFAULT_PATH="/srv/samba/$NAME"
prompt_text " Path to share [$DEFAULT_PATH]:" "$DEFAULT_PATH" SHARE_PATH
SHARE_PATH="${SHARE_PATH:-$DEFAULT_PATH}"
SHARE_PATH="${SHARE_PATH/#\~/$ACTUAL_HOME}"
mkdir -p "$SHARE_PATH"
prompt_yn " Allow guest (no password) access to '$NAME'? (y/n):" "n" GUEST
local VALID_USERS=""
if [[ ! "$GUEST" =~ ^[Yy]$ ]]; then
echo " Enter Samba usernames to grant access to '$NAME' (blank to stop):"
while true; do
local SUSER=""
prompt_text " Username:" "" SUSER
[[ -z "$SUSER" ]] && break
_samba_ensure_user "$SUSER"
VALID_USERS="${VALID_USERS:+$VALID_USERS }$SUSER"
done
if [[ -z "$VALID_USERS" ]]; then
log_warning "No users added and guest access declined — '$NAME' will be inaccessible until you add a user (re-run this installer, or edit smb.conf by hand)."
fi
fi
getent group sambashare >/dev/null 2>&1 || groupadd sambashare
if [[ "$GUEST" =~ ^[Yy]$ ]]; then
chmod 0777 "$SHARE_PATH"
else
chgrp sambashare "$SHARE_PATH" 2>/dev/null || true
chmod 0770 "$SHARE_PATH"
fi
{
echo ""
echo "# ubuntu-post-install:share:$NAME"
echo "[$NAME]"
echo " path = $SHARE_PATH"
echo " browseable = yes"
echo " read only = no"
if [[ "$GUEST" =~ ^[Yy]$ ]]; then
echo " guest ok = yes"
else
echo " guest ok = no"
[[ -n "$VALID_USERS" ]] && echo " valid users = $VALID_USERS"
fi
} >> "$_conf"
log_success "Share '$NAME' -> $SHARE_PATH added to smb.conf"
}
# Creates the Linux system account (if missing) and sets a Samba password for
# it. Samba users need BOTH — a Linux account and a separate smbpasswd entry
# — they are not the same credential, and smbpasswd -a fails outright against
# a username with no matching Linux account at all.
_samba_ensure_user() {
local _user="$1"
if ! id "$_user" &>/dev/null; then
log_info "Linux account '$_user' doesn't exist — creating a system account (no shell login, no home dir)."
useradd --system --no-create-home --shell /usr/sbin/nologin "$_user"
fi
getent group sambashare >/dev/null 2>&1 || groupadd sambashare
usermod -aG sambashare "$_user"
if pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qx "$_user"; then
log_info "Samba password already set for '$_user' — leaving as-is (change it later with: sudo smbpasswd $_user)."
return 0
fi
local _pass _entered=""
_pass="$(generate_password 16)"
prompt_text " Samba password for '$_user' [$_pass]:" "$_pass" _entered
_pass="${_entered:-$_pass}"
if printf '%s\n%s\n' "$_pass" "$_pass" | smbpasswd -s -a "$_user" >/dev/null 2>&1 \
&& smbpasswd -e "$_user" >/dev/null 2>&1; then
log_success "Samba user '$_user' set — password: $_pass (write this down, it isn't stored anywhere else)"
else
log_warning "Failed to set Samba password for '$_user' — set it manually: sudo smbpasswd $_user"
fi
}
# SMB should almost never face the public internet — scope the UFW rule to
# the LAN by default (LAN-subnet detection borrowed from the same pattern
# services/asterisk.sh uses for its VLAN/local-network prompt).
_samba_configure_firewall() {
command -v ufw &>/dev/null || {
log_warning "ufw not installed — if you use a firewall, open TCP 139/445 and UDP 137/138 for SMB (LAN only, never the internet)."
return 0
}
echo ""
local DETECTED_NETS DEFAULT_SUBNET=""
DETECTED_NETS="$(ip -o -f inet addr show scope global 2>/dev/null \
| awk '{print $2, $4}' \
| grep -Ev '^(docker|br-|veth|tun|tap|wg)' \
| awk '{ split($2,a,"/"); split(a[1],o,"."); print o[1]"."o[2]"."o[3]".0/"a[2] }' \
| sort -u)"
DEFAULT_SUBNET="$(echo "$DETECTED_NETS" | head -1)"
local RESTRICT_LAN=""
prompt_yn "Restrict Samba access to your local network only (recommended — SMB should never face the internet)? (y/n):" "y" RESTRICT_LAN
if [[ "$RESTRICT_LAN" =~ ^[Yy]$ ]]; then
local SUBNET=""
prompt_text " LAN subnet to allow (CIDR)${DEFAULT_SUBNET:+ [$DEFAULT_SUBNET]}:" "$DEFAULT_SUBNET" SUBNET
SUBNET="${SUBNET:-$DEFAULT_SUBNET}"
if [[ -z "$SUBNET" ]]; then
log_warning "No subnet given — skipping UFW rules. Open them manually if needed."
return 0
fi
local p
for p in 137 138; do
ufw allow from "$SUBNET" to any port "$p" proto udp comment "Samba (LAN)" >/dev/null 2>&1
done
for p in 139 445; do
ufw allow from "$SUBNET" to any port "$p" proto tcp comment "Samba (LAN)" >/dev/null 2>&1
done
log_success "UFW: Samba opened to $SUBNET only"
else
log_warning "Opening Samba to ALL sources — not recommended, SMB has a long history of remote exploits."
ufw allow 137/udp comment "Samba" >/dev/null 2>&1
ufw allow 138/udp comment "Samba" >/dev/null 2>&1
ufw allow 139/tcp comment "Samba" >/dev/null 2>&1
ufw allow 445/tcp comment "Samba" >/dev/null 2>&1
log_success "UFW: Samba opened (unrestricted)"
fi
ensure_ufw_enabled
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_samba
+52 -3
View File
@@ -174,6 +174,19 @@ CBLOCK
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -206,11 +219,29 @@ install_wolf-pair() {
echo " - Build the wolf-pair image (python:3.12-alpine + docker-cli)"
echo " - Run the container with network_mode: host (for localhost:47989 access)"
echo " - Mount /var/run/docker.sock:ro (for docker logs wolf)"
echo " - Open port $WOLFPAIR_PORT in UFW"
echo " - Open port $WOLFPAIR_PORT in UFW (auto-scanned for a free host port —"
echo " other services, e.g. wordpress/ntfy/beszel, default to 8090 too)"
echo " - Optionally configure a Caddy reverse proxy"
return 0
fi
# network_mode: host means there's no HOST:CONTAINER ports: mapping to scan
# around a collision on — server.py binds 0.0.0.0 directly on the host, so a
# taken 8090 (wordpress/ntfy/beszel all default here too) fails at container
# start with "address already in use" and nothing in docker-compose.yml to
# point at. Scan once and persist in .env; on a rerun, keep the port already
# in use rather than silently moving it out from under an existing Caddy
# site block / bookmarked URL.
if [ -f "$WOLFPAIR_DIR/.env" ]; then
local _existing_port
_existing_port="$(grep '^WOLFPAIR_PORT=' "$WOLFPAIR_DIR/.env" 2>/dev/null | cut -d= -f2-)"
[ -n "$_existing_port" ] && WOLFPAIR_PORT="$_existing_port"
else
find_free_port WOLFPAIR_PORT "$WOLFPAIR_PORT"
fi
[ "$WOLFPAIR_PORT" != "8090" ] && \
log_info "Port 8090 already in use — wolf-pair will use $WOLFPAIR_PORT instead."
mkdir -p "$WOLFPAIR_DIR"
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
cd "$WOLFPAIR_DIR" || return 1
@@ -234,10 +265,11 @@ submitted — otherwise the user resubmits a dead secret and Wolf returns
"key not found". We track submitted secrets and fall back to the waiting page
until Moonlight initiates a brand-new pairing (which mints a new secret).
"""
import json, subprocess, re, urllib.request, urllib.error
import json, os, subprocess, re, urllib.request, urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
WOLF_HTTP = "http://localhost:47989"
LISTEN_PORT = int(os.environ.get("WOLFPAIR_PORT", "8090"))
# Secrets already submitted to Wolf. Wolf erases a secret on first submit, so a
# secret in here is dead — show the waiting page instead of re-offering it.
@@ -390,7 +422,7 @@ class Handler(BaseHTTPRequestHandler):
if __name__ == '__main__':
HTTPServer.allow_reuse_address = True
HTTPServer(('0.0.0.0', 8090), Handler).serve_forever()
HTTPServer(('0.0.0.0', LISTEN_PORT), Handler).serve_forever()
PYEOF
log_success "server.py written"
@@ -420,12 +452,27 @@ services:
dockerfile: Dockerfile
container_name: wolf-pair
network_mode: host
environment:
- WOLFPAIR_PORT=${WOLFPAIR_PORT:-8090}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
COMPOSE
log_success "docker-compose.yml written"
# host networking means server.py binds this port directly — .env feeds it
# to the container's WOLFPAIR_PORT (above) via docker compose's own .env
# auto-load, same pattern as WOLF_STATE_DIR in services/wolf.sh.
backup_if_exists "$WOLFPAIR_DIR/.env"
cat > "$WOLFPAIR_DIR/.env" << EOF
# Port wolf-pair's pairing UI listens on (host networking — no port mapping
# to edit). Auto-scanned at install time to avoid clashing with other
# services that also default to 8090 (wordpress, ntfy, beszel).
WOLFPAIR_PORT=${WOLFPAIR_PORT}
EOF
chmod 600 "$WOLFPAIR_DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR/.env"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR"
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
@@ -491,6 +538,8 @@ docker compose logs -f # follow logs
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
subdomain is for the PIN form only.
MD
[ "$WOLFPAIR_PORT" != "8090" ] && \
sed -i "s/localhost:8090/localhost:${WOLFPAIR_PORT}/g" "$WOLFPAIR_DIR/README.md"
# ── 7. Build & start ──────────────────────────────────────────────────────
echo ""
+3036 -100
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -121,10 +121,12 @@ is_installed() {
base) command -v ncdu >/dev/null 2>&1 ;;
glow) command -v glow >/dev/null 2>&1 ;;
crowdsec) command -v cscli >/dev/null 2>&1 ;;
samba) command -v smbd >/dev/null 2>&1 ;;
security-dashboard) [ -f /opt/security-dashboard/app.py ] ;;
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
claude-cli) [ -f "$ACTUAL_HOME/.claude-shared/CLAUDE.md" ] ;;
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;
sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;;
# Either directory counts: boxes set up before the droplet edition was
@@ -155,7 +157,7 @@ is_installed() {
# is_installed() as 0 or 1.
install_count() {
case "$1" in
base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import)
base|glow|crowdsec|samba|security-dashboard|kdeconnect|silent-send|sync-cc|claude-cli|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config|ssh-key-import)
is_installed "$1" && echo 1 || echo 0 ;;
wordpress)
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""tools/anki-deck-math.py — Generate math-fact Anki decks (.apkg) with a
vertical/stacked problem layout, Anki's built-in type-the-answer input, and
Piper (offline, local neural TTS) audio on both the question and answer
side of every card.
Standalone content-generation tool, unrelated to this repo's services/*.sh
installers — run it on any machine with Python (your desktop, laptop, or
the same box running services/anki-sync-server.sh), then import the
resulting .apkg into Anki (File -> Import) or push it into a sync-server
account with AnkiConnect's importPackage action. See services/anki-sync-server.sh
and services/anki-progress.sh for the actual self-hosted sync backend and
progress dashboard this content is meant to be studied through.
Decks:
multiplication 1-12, all 144 ordered pairs (a x b), shuffled
(not sequential — see the note near
random.shuffle(pairs) below for why)
division inverse of the multiplication deck (144 facts)
addsub --lo L --hi H addition + subtraction fact family for [L, H]
(subtraction facts derived from the addition
facts, e.g. 7+3=10 also gives 10-7=3 and
10-3=7 — never negative results)
fractions reducing fractions to lowest terms (denominators 2-12)
decimals fraction -> decimal conversion (only denominators
whose decimal expansion terminates: 2,4,5,8,10,20,25)
Setup (one time):
python3 -m venv ~/anki-deck-venv
source ~/anki-deck-venv/bin/activate
pip install genanki piper-tts
# Download at least one voice (one time per voice you want to try —
# download_voices saves into the CURRENT directory by default, so cd
# somewhere sensible first, e.g. your home directory):
python3 -m piper.download_voices en_US-lessac-medium
# Other options: en_US-amy-medium (warm/friendly), en_US-ryan-high
# (best-quality US male), en_US-libritts_r-medium (multi-speaker),
# en_GB-alba-medium / en_GB-cori-high (British accent). Tiers are
# low < medium < high — higher sounds more natural but is bigger/slower.
# Sanity-check the voice before generating a full deck's worth of clips:
echo "three times seven" | python3 -m piper -m en_US-lessac-medium.onnx -f /tmp/test.wav
# play /tmp/test.wav and confirm it sounds right first.
Usage (run with the venv activated):
python3 anki-deck-math.py --deck multiplication
python3 anki-deck-math.py --deck division
python3 anki-deck-math.py --deck addsub --lo 3 --hi 7
python3 anki-deck-math.py --deck addsub --lo 3 --hi 13
python3 anki-deck-math.py --deck addsub --lo 2 --hi 21
python3 anki-deck-math.py --deck fractions
python3 anki-deck-math.py --deck decimals
(add --voice en_US-amy-medium etc. to any of the above to use a voice other
than the default en_US-lessac-medium; --model-path to point at a voice
file directly if it's not found in any of the usual places checked
automatically; --dry-run-tts to test the deck-building logic itself
without Piper or any voice model at all, using silent placeholder audio)
The addsub --hi 21 deck generates ~1600 audio clips and will take noticeably
longer than the others — consider `nohup python3 anki-deck-math.py --deck
addsub --lo 2 --hi 21 > addsub.log 2>&1 &` if you don't want to wait on it.
"""
import argparse
import hashlib
import genanki
import math
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True,
choices=["multiplication", "division", "addsub", "fractions", "decimals"])
parser.add_argument("--lo", type=int, default=None, help="addsub only: low end of range")
parser.add_argument("--hi", type=int, default=None, help="addsub only: high end of range")
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true",
help="Skip Piper entirely and write silent placeholder audio instead"
" (for testing the deck-building logic without a voice model).")
args = parser.parse_args()
if args.deck == "addsub":
if args.lo is None or args.hi is None:
raise SystemExit("--deck addsub requires --lo and --hi, e.g. --lo 3 --hi 7")
if args.lo >= args.hi:
raise SystemExit("--lo must be less than --hi")
SCRATCH = os.path.dirname(os.path.abspath(__file__))
# ─── Voice resolution (same search order as the multiplication script) ──────
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
# 44-byte minimal valid WAV header, zero samples — enough for genanki
# to accept it as a real media file without needing Piper installed.
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
# ─── Number -> words ─────────────────────────────────────────────────────────
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 0:
return "negative " + num2words(-n)
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
_NUM_CHECKS = {0: "zero", 9: "nine", 10: "ten", 13: "thirteen", 20: "twenty",
21: "twenty-one", 45: "forty-five", 99: "ninety-nine",
100: "one hundred", 110: "one hundred ten",
121: "one hundred twenty-one", 144: "one hundred forty-four",
441: "four hundred forty-one"}
for _n, _w in _NUM_CHECKS.items():
assert num2words(_n) == _w, f"num2words({_n}) = {num2words(_n)!r}, expected {_w!r}"
# Ordinal words, singular form, denominators 2-21 (covers every deck below).
# Irregular forms (half, third, fifth, eighth, ninth, twelfth) are real
# English irregularities, not a suffix rule, so this is a lookup table, not
# a formula — a formula would get exactly these wrong.
ORDINAL_SINGULAR = {
2: "half", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth",
7: "seventh", 8: "eighth", 9: "ninth", 10: "tenth", 11: "eleventh",
12: "twelfth", 13: "thirteenth", 14: "fourteenth", 15: "fifteenth",
16: "sixteenth", 17: "seventeenth", 18: "eighteenth", 19: "nineteenth",
20: "twentieth", 21: "twenty-first", 25: "twenty-fifth", 50: "fiftieth",
100: "hundredth",
}
def ordinal_plural(n):
s = ORDINAL_SINGULAR[n]
return "halves" if s == "half" else s + "s"
def fraction_words(num, den):
"""'three fourths', 'one half', 'seven tenths'."""
ord_word = ORDINAL_SINGULAR[den] if num == 1 else ordinal_plural(den)
return f"{num2words(num)} {ord_word}"
_FRAC_CHECKS = {
(1, 2): "one half", (3, 4): "three fourths", (1, 4): "one fourth",
(7, 10): "seven tenths", (1, 3): "one third", (2, 3): "two thirds",
(5, 8): "five eighths", (1, 8): "one eighth",
}
for (_n, _d), _w in _FRAC_CHECKS.items():
assert fraction_words(_n, _d) == _w, f"fraction_words({_n},{_d}) = {fraction_words(_n, _d)!r}, expected {_w!r}"
def decimal_words(decimal_str):
"""'0.25' -> 'zero point two five' (each digit spoken individually,
avoids any ambiguity between e.g. 'point two five' vs 'twenty-five
hundredths')."""
whole, frac = decimal_str.split(".")
digit_words = " ".join(ONES[int(d)] for d in frac)
return f"{num2words(int(whole))} point {digit_words}"
assert decimal_words("0.25") == "zero point two five"
assert decimal_words("0.5") == "zero point five"
assert decimal_words("0.375") == "zero point three seven five"
# ─── Shared genanki model builder ────────────────────────────────────────────
# Every deck here renders as two stacked lines with a line under them (same
# visual language as the original multiplication deck): TOP over BOTTOM,
# with an optional prefix (operator) on the bottom line. Fractions/decimals
# reuse the exact same layout as numerator-over-denominator.
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_600_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Math Fact ({deck_key}, {args.voice})",
fields=[{"name": "Top"}, {"name": "Bottom"}, {"name": "Answer"},
{"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="problem">
<div class="line1">{{Top}}</div>
<div class="line2">{{Bottom}}</div>
<div class="rule"></div>
</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 28px; text-align: center; }
.problem { display: inline-block; text-align: right; margin: 20px auto; }
.line1, .line2 { font-size: 48px; padding: 2px 10px; }
.rule { border-top: 3px solid black; margin-top: 4px; width: 100%; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_000_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, top, bottom, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[top, bottom, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_multiplication():
deck_key = "multiplication"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Multiplication Facts (1-12)")
media_files = []
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
random.seed(42)
random.shuffle(pairs)
for a, b in pairs:
ans = a * b
add_note(deck, model, str(a), f"&times; {b}", str(ans),
f"{num2words(a)} times {num2words(b)}", num2words(ans),
media_files, f"mul_{a}_{b}")
return deck, media_files, len(pairs)
def gen_division():
deck_key = "division"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Division Facts (inverse of 1-12 times tables)")
media_files = []
# Same (a, b) pairs as multiplication: product / a = b. This is the
# direct inverse of every multiplication card in that deck.
pairs = [(a, b) for a in range(1, 13) for b in range(1, 13)]
random.seed(43)
random.shuffle(pairs)
for a, b in pairs:
product = a * b
add_note(deck, model, str(product), f"&divide; {a}", str(b),
f"{num2words(product)} divided by {num2words(a)}", num2words(b),
media_files, f"div_{a}_{b}")
return deck, media_files, len(pairs)
def gen_addsub(lo, hi):
deck_key = f"addsub_{lo}_{hi}"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, f"Addition & Subtraction Facts ({lo}-{hi})")
media_files = []
add_pairs = [(a, b) for a in range(lo, hi + 1) for b in range(lo, hi + 1)]
random.seed(hash((lo, hi)) & 0xFFFFFFFF)
random.shuffle(add_pairs)
sub_facts = [] # (minuend, subtrahend, answer)
seen = set()
for a, b in add_pairs:
c = a + b
for minuend, subtrahend, answer in ((c, a, b), (c, b, a)):
key = (minuend, subtrahend)
if key not in seen:
seen.add(key)
sub_facts.append((minuend, subtrahend, answer))
random.shuffle(sub_facts)
count = 0
for a, b in add_pairs:
ans = a + b
add_note(deck, model, str(a), f"+ {b}", str(ans),
f"{num2words(a)} plus {num2words(b)}", num2words(ans),
media_files, f"add_{lo}_{hi}_{a}_{b}")
count += 1
for minuend, subtrahend, answer in sub_facts:
add_note(deck, model, str(minuend), f"&minus; {subtrahend}", str(answer),
f"{num2words(minuend)} minus {num2words(subtrahend)}", num2words(answer),
media_files, f"sub_{lo}_{hi}_{minuend}_{subtrahend}")
count += 1
return deck, media_files, count
def gen_fractions():
deck_key = "fractions"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Reducing Fractions to Lowest Terms")
media_files = []
facts = []
for den in range(2, 13):
for num in range(1, den):
g = math.gcd(num, den)
if g > 1:
facts.append((num, den, num // g, den // g))
random.seed(44)
random.shuffle(facts)
for num, den, rnum, rden in facts:
answer = f"{rnum}/{rden}"
add_note(deck, model, str(num), f"&frasl; {den}", answer,
fraction_words(num, den), fraction_words(rnum, rden),
media_files, f"frac_{num}_{den}")
return deck, media_files, len(facts)
def gen_decimals():
deck_key = "decimals"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Fraction to Decimal Conversion")
media_files = []
# Only denominators whose only prime factors are 2 and 5 terminate in a
# finite decimal (1/3 = 0.333... never terminates) — restricting to
# these avoids ever needing to round/repeat.
facts = []
for den in (2, 4, 5, 8, 10, 20, 25):
for num in range(1, den):
if math.gcd(num, den) != 1:
continue # skip non-lowest-terms fractions (already covered by the fractions deck)
value = num / den
decimal_str = f"{value:.10f}".rstrip("0")
if decimal_str.endswith("."):
decimal_str += "0"
facts.append((num, den, decimal_str))
random.seed(45)
random.shuffle(facts)
for num, den, decimal_str in facts:
add_note(deck, model, str(num), f"&frasl; {den}", decimal_str,
fraction_words(num, den), decimal_words(decimal_str),
media_files, f"dec_{num}_{den}")
return deck, media_files, len(facts)
# ─── Dispatch ─────────────────────────────────────────────────────────────────
if args.deck == "addsub":
deck_key = f"addsub_{args.lo}_{args.hi}"
else:
deck_key = args.deck
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {
"multiplication": lambda: gen_multiplication(),
"division": lambda: gen_division(),
"addsub": lambda: gen_addsub(args.lo, args.hi),
"fractions": lambda: gen_fractions(),
"decimals": lambda: gen_decimals(),
}
deck, media_files, count = GENERATORS[args.deck]()
if count == 0:
raise SystemExit(f"No cards generated for --deck {args.deck} — check the range/args.")
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""tools/anki-deck-periodic.py — Generate periodic table Anki decks (.apkg)
with Anki's built-in type-the-answer input and Piper (offline, local
neural TTS) audio on both sides. See tools/anki-deck-math.py's docstring
for one-time setup (venv, genanki + piper-tts, downloading a voice) — same
steps apply here, this is a standalone, self-contained script otherwise.
Decks:
prehs symbol<->name, elements 1-36 (H through Kr)
hs symbol<->name plus number->symbol, all 118 elements
category element category as multiple choice (A/B/C/D shown as
plain text options — not a clickable UI, since that needs
a desktop-only Anki add-on and would break on
AnkiDroid/AnkiMobile), type the letter — only elements
with a confirmed category (excludes 8 very recent
superheavy elements whose category is still officially
unconfirmed)
Element data: Bowserinator/Periodic-Table-JSON (a widely used, actively
maintained public dataset), fetched and spot-checked against known facts
before being embedded below — not typed from memory.
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-periodic.py --deck prehs
python3 anki-deck-periodic.py --deck hs
python3 anki-deck-periodic.py --deck category
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["prehs", "hs", "category"])
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
assert num2words(1) == "one"
assert num2words(26) == "twenty-six"
assert num2words(118) == "one hundred eighteen"
# ─── Element data: (atomic_number, symbol, name, category-or-None) ─────────
# category is None for the 8 most recently synthesized superheavy elements
# whose chemical category is still officially unconfirmed (excluded from
# the category deck below, still included in prehs/hs symbol/name/number).
ELEMENTS = [
(1, 'H', 'Hydrogen', 'diatomic nonmetal'),
(2, 'He', 'Helium', 'noble gas'),
(3, 'Li', 'Lithium', 'alkali metal'),
(4, 'Be', 'Beryllium', 'alkaline earth metal'),
(5, 'B', 'Boron', 'metalloid'),
(6, 'C', 'Carbon', 'polyatomic nonmetal'),
(7, 'N', 'Nitrogen', 'diatomic nonmetal'),
(8, 'O', 'Oxygen', 'diatomic nonmetal'),
(9, 'F', 'Fluorine', 'diatomic nonmetal'),
(10, 'Ne', 'Neon', 'noble gas'),
(11, 'Na', 'Sodium', 'alkali metal'),
(12, 'Mg', 'Magnesium', 'alkaline earth metal'),
(13, 'Al', 'Aluminium', 'post-transition metal'),
(14, 'Si', 'Silicon', 'metalloid'),
(15, 'P', 'Phosphorus', 'polyatomic nonmetal'),
(16, 'S', 'Sulfur', 'polyatomic nonmetal'),
(17, 'Cl', 'Chlorine', 'diatomic nonmetal'),
(18, 'Ar', 'Argon', 'noble gas'),
(19, 'K', 'Potassium', 'alkali metal'),
(20, 'Ca', 'Calcium', 'alkaline earth metal'),
(21, 'Sc', 'Scandium', 'transition metal'),
(22, 'Ti', 'Titanium', 'transition metal'),
(23, 'V', 'Vanadium', 'transition metal'),
(24, 'Cr', 'Chromium', 'transition metal'),
(25, 'Mn', 'Manganese', 'transition metal'),
(26, 'Fe', 'Iron', 'transition metal'),
(27, 'Co', 'Cobalt', 'transition metal'),
(28, 'Ni', 'Nickel', 'transition metal'),
(29, 'Cu', 'Copper', 'transition metal'),
(30, 'Zn', 'Zinc', 'transition metal'),
(31, 'Ga', 'Gallium', 'post-transition metal'),
(32, 'Ge', 'Germanium', 'metalloid'),
(33, 'As', 'Arsenic', 'metalloid'),
(34, 'Se', 'Selenium', 'polyatomic nonmetal'),
(35, 'Br', 'Bromine', 'diatomic nonmetal'),
(36, 'Kr', 'Krypton', 'noble gas'),
(37, 'Rb', 'Rubidium', 'alkali metal'),
(38, 'Sr', 'Strontium', 'alkaline earth metal'),
(39, 'Y', 'Yttrium', 'transition metal'),
(40, 'Zr', 'Zirconium', 'transition metal'),
(41, 'Nb', 'Niobium', 'transition metal'),
(42, 'Mo', 'Molybdenum', 'transition metal'),
(43, 'Tc', 'Technetium', 'transition metal'),
(44, 'Ru', 'Ruthenium', 'transition metal'),
(45, 'Rh', 'Rhodium', 'transition metal'),
(46, 'Pd', 'Palladium', 'transition metal'),
(47, 'Ag', 'Silver', 'transition metal'),
(48, 'Cd', 'Cadmium', 'transition metal'),
(49, 'In', 'Indium', 'post-transition metal'),
(50, 'Sn', 'Tin', 'post-transition metal'),
(51, 'Sb', 'Antimony', 'metalloid'),
(52, 'Te', 'Tellurium', 'metalloid'),
(53, 'I', 'Iodine', 'diatomic nonmetal'),
(54, 'Xe', 'Xenon', 'noble gas'),
(55, 'Cs', 'Cesium', 'alkali metal'),
(56, 'Ba', 'Barium', 'alkaline earth metal'),
(57, 'La', 'Lanthanum', 'lanthanide'),
(58, 'Ce', 'Cerium', 'lanthanide'),
(59, 'Pr', 'Praseodymium', 'lanthanide'),
(60, 'Nd', 'Neodymium', 'lanthanide'),
(61, 'Pm', 'Promethium', 'lanthanide'),
(62, 'Sm', 'Samarium', 'lanthanide'),
(63, 'Eu', 'Europium', 'lanthanide'),
(64, 'Gd', 'Gadolinium', 'lanthanide'),
(65, 'Tb', 'Terbium', 'lanthanide'),
(66, 'Dy', 'Dysprosium', 'lanthanide'),
(67, 'Ho', 'Holmium', 'lanthanide'),
(68, 'Er', 'Erbium', 'lanthanide'),
(69, 'Tm', 'Thulium', 'lanthanide'),
(70, 'Yb', 'Ytterbium', 'lanthanide'),
(71, 'Lu', 'Lutetium', 'lanthanide'),
(72, 'Hf', 'Hafnium', 'transition metal'),
(73, 'Ta', 'Tantalum', 'transition metal'),
(74, 'W', 'Tungsten', 'transition metal'),
(75, 'Re', 'Rhenium', 'transition metal'),
(76, 'Os', 'Osmium', 'transition metal'),
(77, 'Ir', 'Iridium', 'transition metal'),
(78, 'Pt', 'Platinum', 'transition metal'),
(79, 'Au', 'Gold', 'transition metal'),
(80, 'Hg', 'Mercury', 'transition metal'),
(81, 'Tl', 'Thallium', 'post-transition metal'),
(82, 'Pb', 'Lead', 'post-transition metal'),
(83, 'Bi', 'Bismuth', 'post-transition metal'),
(84, 'Po', 'Polonium', 'post-transition metal'),
(85, 'At', 'Astatine', 'diatomic nonmetal'),
(86, 'Rn', 'Radon', 'noble gas'),
(87, 'Fr', 'Francium', 'alkali metal'),
(88, 'Ra', 'Radium', 'alkaline earth metal'),
(89, 'Ac', 'Actinium', 'actinide'),
(90, 'Th', 'Thorium', 'actinide'),
(91, 'Pa', 'Protactinium', 'actinide'),
(92, 'U', 'Uranium', 'actinide'),
(93, 'Np', 'Neptunium', 'actinide'),
(94, 'Pu', 'Plutonium', 'actinide'),
(95, 'Am', 'Americium', 'actinide'),
(96, 'Cm', 'Curium', 'actinide'),
(97, 'Bk', 'Berkelium', 'actinide'),
(98, 'Cf', 'Californium', 'actinide'),
(99, 'Es', 'Einsteinium', 'actinide'),
(100, 'Fm', 'Fermium', 'actinide'),
(101, 'Md', 'Mendelevium', 'actinide'),
(102, 'No', 'Nobelium', 'actinide'),
(103, 'Lr', 'Lawrencium', 'actinide'),
(104, 'Rf', 'Rutherfordium', 'transition metal'),
(105, 'Db', 'Dubnium', 'transition metal'),
(106, 'Sg', 'Seaborgium', 'transition metal'),
(107, 'Bh', 'Bohrium', 'transition metal'),
(108, 'Hs', 'Hassium', 'transition metal'),
(109, 'Mt', 'Meitnerium', None),
(110, 'Ds', 'Darmstadtium', None),
(111, 'Rg', 'Roentgenium', None),
(112, 'Cn', 'Copernicium', None),
(113, 'Nh', 'Nihonium', 'post-transition metal'),
(114, 'Fl', 'Flerovium', 'post-transition metal'),
(115, 'Mc', 'Moscovium', None),
(116, 'Lv', 'Livermorium', None),
(117, 'Ts', 'Tennessine', None),
(118, 'Og', 'Oganesson', None),
]
assert len(ELEMENTS) == 118
assert [e[0] for e in ELEMENTS] == list(range(1, 119))
assert ELEMENTS[0] == (1, 'H', 'Hydrogen', 'diatomic nonmetal')
assert ELEMENTS[25] == (26, 'Fe', 'Iron', 'transition metal')
assert ELEMENTS[-1] == (118, 'Og', 'Oganesson', None)
ALL_CATEGORIES = sorted({e[3] for e in ELEMENTS if e[3] is not None})
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_700_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Periodic Table ({deck_key}, {args.voice})",
fields=[{"name": "Prompt"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="prompt">{{Prompt}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="prompt">{{Prompt}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 26px; text-align: center; }
.prompt { font-size: 40px; margin: 20px auto; white-space: pre-line; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_100_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, prompt, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[prompt, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def gen_prehs():
deck_key = "periodic_prehs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols & Names (1-36)")
media_files = []
subset = [e for e in ELEMENTS if e[0] <= 36]
cards = []
for number, symbol, name, category in subset:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
random.seed(50)
random.shuffle(cards)
for kind, number, symbol, name in cards:
if kind == "symbol_to_name":
add_note(deck, model, symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"prehs_s2n_{number}")
else:
add_note(deck, model, name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"prehs_n2s_{number}")
return deck, media_files, len(cards)
def gen_hs():
deck_key = "periodic_hs"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Symbols, Names & Numbers (1-118)")
media_files = []
cards = []
for number, symbol, name, category in ELEMENTS:
cards.append(("symbol_to_name", number, symbol, name))
cards.append(("name_to_symbol", number, symbol, name))
cards.append(("number_to_symbol", number, symbol, name))
random.seed(51)
random.shuffle(cards)
for kind, number, symbol, name in cards:
if kind == "symbol_to_name":
add_note(deck, model, symbol, name,
f"What element has the symbol {symbol}?", name,
media_files, f"hs_s2n_{number}")
elif kind == "name_to_symbol":
add_note(deck, model, name, symbol,
f"What is the symbol for {name}?", symbol,
media_files, f"hs_n2s_{number}")
else:
add_note(deck, model, f"Element #{number}", symbol,
f"What is the symbol for element number {num2words(number)}?", symbol,
media_files, f"hs_num2s_{number}")
return deck, media_files, len(cards)
def gen_category():
deck_key = "periodic_category"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Periodic Table: Element Categories (multiple choice)")
media_files = []
subset = [e for e in ELEMENTS if e[3] is not None]
random.seed(52)
shuffled = subset[:]
random.shuffle(shuffled)
letters = ["A", "B", "C", "D"]
for number, symbol, name, category in shuffled:
distractor_pool = [c for c in ALL_CATEGORIES if c != category]
distractors = random.sample(distractor_pool, 3)
choices = distractors + [category]
random.shuffle(choices)
correct_letter = letters[choices.index(category)]
prompt_lines = [f"{name} ({symbol})", ""]
for letter, choice in zip(letters, choices):
prompt_lines.append(f"{letter}) {choice}")
prompt = "\n".join(prompt_lines)
qtext = f"What category is {name}?"
atext = f"{category}"
add_note(deck, model, prompt, correct_letter, qtext, atext,
media_files, f"cat_{number}")
return deck, media_files, len(subset)
if args.deck == "prehs":
deck_key = "periodic_prehs"
elif args.deck == "hs":
deck_key = "periodic_hs"
else:
deck_key = "periodic_category"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"prehs": gen_prehs, "hs": gen_hs, "category": gen_category}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} audio clips)")
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""tools/anki-deck-visual.py — Generate image-based Anki decks (.apkg) for
shapes, clocks, and coin-counting, with Anki's built-in type-the-answer
input and Piper (offline, local neural TTS) audio. See
tools/anki-deck-math.py's docstring for one-time setup (venv, genanki +
piper-tts, downloading a voice) — same steps apply here.
All images are drawn programmatically as SVG (regular-polygon geometry,
clock-hand trigonometry, coin layouts) rather than AI-generated — image
generation (local or cloud) is a poor fit for content that has to be
exactly correct (an exact clock time, an exact side count, an exact coin
total), not just plausible-looking. See this script's own point/angle
generation functions for how each shape's geometry is computed directly
rather than approximated.
Decks:
shapes regular polygons (3-10 sides, image->name and name->sides)
plus 5 quadrilateral types (image->name: square, rectangle,
rhombus, trapezoid, parallelogram — each one's geometry is
genuinely distinct, not just differently labeled)
clocks analog clock faces, all 144 hour/5-min combinations,
type the time as H:MM (the hour hand moves fractionally
with the minutes, e.g. 6:30 sits halfway between 6 and 7 —
a static hour hand is the most common "looks right but
teaches wrong" bug in generated clock faces)
currency US coins (nickel/dime/quarter — no pennies, since they're
barely used day to day at this point), 1-4 coins per card,
type the total in cents
Usage (run with the venv from anki-deck-math.py's docstring activated):
python3 anki-deck-visual.py --deck shapes
python3 anki-deck-visual.py --deck clocks
python3 anki-deck-visual.py --deck currency
(add --voice en_US-amy-medium etc.; --model-path if a voice isn't found
automatically; --dry-run-tts to test the deck-building logic without any
voice model at all, using silent placeholder audio)
"""
import argparse
import hashlib
import genanki
import math
import os
import random
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--deck", required=True, choices=["shapes", "clocks", "currency"])
parser.add_argument("--voice", default="en_US-lessac-medium")
parser.add_argument("--model-path", default=None)
parser.add_argument("--dry-run-tts", action="store_true")
args = parser.parse_args()
SCRATCH = os.path.dirname(os.path.abspath(__file__))
_CANDIDATES = [
args.model_path,
f"{args.voice}.onnx",
os.path.join(SCRATCH, f"{args.voice}.onnx"),
os.path.expanduser(f"~/{args.voice}.onnx"),
os.path.expanduser(f"~/.local/share/piper/voices/{args.voice}.onnx"),
]
VOICE_MODEL = next((p for p in _CANDIDATES if p and os.path.isfile(p)), None)
if VOICE_MODEL is None and not args.dry_run_tts:
raise SystemExit(
f"Voice model for '{args.voice}' not found. Checked:\n"
+ "\n".join(f" {p}" for p in _CANDIDATES if p)
+ f"\n\nFind it with: find / -iname '{args.voice}.onnx' 2>/dev/null"
+ "\nThen pass its exact path with --model-path /the/real/path.onnx"
+ "\n(or pass --dry-run-tts to test deck-building without any voice at all)"
)
def piper_tts(text: str, out_path: str) -> None:
if args.dry_run_tts:
with open(out_path, "wb") as f:
f.write(
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
b"\x22\x56\x00\x00\x44\xac\x00\x00\x02\x00\x10\x00data\x00\x00\x00\x00"
)
return
subprocess.run(
["python3", "-m", "piper", "-m", VOICE_MODEL, "-f", out_path],
input=text.encode("utf-8"),
check=True,
capture_output=True,
)
ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven",
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen"]
TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def num2words(n):
if n < 20:
return ONES[n]
if n < 100:
t, o = divmod(n, 10)
return TENS[t] + ("-" + ONES[o] if o else "")
h, rem = divmod(n, 100)
return ONES[h] + " hundred" + (" " + num2words(rem) if rem else "")
assert num2words(15) == "fifteen"
assert num2words(40) == "forty"
def time_words(hour, minute):
"""3, 5 -> 'three oh five'; 3, 15 -> 'three fifteen'; 3, 0 -> 'three o'clock'."""
if minute == 0:
return f"{num2words(hour)} o'clock"
if minute < 10:
return f"{num2words(hour)} oh {num2words(minute)}"
return f"{num2words(hour)} {num2words(minute)}"
assert time_words(3, 0) == "three o'clock"
assert time_words(3, 5) == "three oh five"
assert time_words(3, 15) == "three fifteen"
assert time_words(12, 45) == "twelve forty-five"
def build_model(deck_key):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
model_id = 1_800_000_000 + (voice_hash % 90_000_000)
return model_id, genanki.Model(
model_id,
f"Visual Fact ({deck_key}, {args.voice})",
fields=[{"name": "Image"}, {"name": "Answer"}, {"name": "QSound"}, {"name": "ASound"}],
templates=[{
"name": "Card",
"qfmt": """
<div class="imgwrap">{{Image}}</div>
{{QSound}}
{{type:Answer}}
""",
"afmt": """
<div class="imgwrap">{{Image}}</div>
<hr id="answer">
{{type:Answer}}
{{ASound}}
""",
}],
css="""
.card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; }
.imgwrap { margin: 10px auto; }
.imgwrap img { max-width: 260px; max-height: 260px; }
""",
)
def build_deck(deck_key, deck_title):
voice_hash = int(hashlib.sha256(f"{deck_key}:{args.voice}".encode()).hexdigest(), 16)
deck_id = 2_200_000_000 + (voice_hash % 90_000_000)
return genanki.Deck(deck_id, deck_title)
def add_note(deck, model, image_html, answer, qtext, atext, media_files, tag):
qfile = f"q_{tag}.wav"
afile = f"a_{tag}.wav"
qpath = os.path.join(MEDIA_DIR, qfile)
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(qtext, qpath)
piper_tts(atext, apath)
media_files += [qpath, apath]
deck.add_note(genanki.Note(
model=model,
fields=[image_html, answer, f"[sound:{qfile}]", f"[sound:{afile}]"],
))
def add_text_note(deck, model, text, answer, qtext, atext, media_files, tag):
"""For directions that don't need an image (e.g. name -> number of sides)."""
add_note(deck, model, f'<div style="font-size:36px;">{text}</div>', answer,
qtext, atext, media_files, tag)
# ─── SVG generation ───────────────────────────────────────────────────────────
def save_svg(svg_body, filename, viewbox="0 0 200 200"):
path = os.path.join(MEDIA_DIR, filename)
with open(path, "w") as f:
f.write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{viewbox}" '
f'width="200" height="200">{svg_body}</svg>'
)
return path
def regular_polygon_points(n_sides, cx=100, cy=100, r=80):
points = []
# Start pointing up (-90deg) so shapes sit "upright" rather than vertex-right.
start_angle = -90
for i in range(n_sides):
angle_deg = start_angle + i * (360 / n_sides)
angle_rad = math.radians(angle_deg)
x = cx + r * math.cos(angle_rad)
y = cy + r * math.sin(angle_rad)
points.append((round(x, 1), round(y, 1)))
return points
def polygon_svg(points):
pts_str = " ".join(f"{x},{y}" for x, y in points)
return f'<polygon points="{pts_str}" fill="#6fa8dc" stroke="#1c4587" stroke-width="4"/>'
POLYGON_NAMES = {
3: "triangle", 4: "square", 5: "pentagon", 6: "hexagon", 7: "heptagon",
8: "octagon", 9: "nonagon", 10: "decagon",
}
QUADRILATERALS = {
"square": [(50, 50), (150, 50), (150, 150), (50, 150)],
"rectangle": [(30, 60), (170, 60), (170, 140), (30, 140)],
"rhombus": [(100, 20), (170, 100), (100, 180), (30, 100)],
"trapezoid": [(60, 60), (140, 60), (170, 140), (30, 140)],
"parallelogram": [(60, 60), (160, 60), (140, 140), (40, 140)],
}
def clock_svg(hour, minute):
cx, cy, r = 100, 100, 90
minute_angle = minute * 6 - 90
hour_angle = (hour % 12) * 30 + minute * 0.5 - 90
def hand(angle_deg, length, width, color):
rad = math.radians(angle_deg)
x2 = cx + length * math.cos(rad)
y2 = cy + length * math.sin(rad)
return f'<line x1="{cx}" y1="{cy}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{color}" stroke-width="{width}" stroke-linecap="round"/>'
ticks = []
numerals = []
for h in range(1, 13):
angle = math.radians(h * 30 - 90)
tx1, ty1 = cx + (r - 10) * math.cos(angle), cy + (r - 10) * math.sin(angle)
tx2, ty2 = cx + r * math.cos(angle), cy + r * math.sin(angle)
ticks.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" stroke="black" stroke-width="2"/>')
nx, ny = cx + (r - 22) * math.cos(angle), cy + (r - 22) * math.sin(angle)
numerals.append(f'<text x="{nx:.1f}" y="{ny:.1f}" font-size="14" text-anchor="middle" dominant-baseline="middle">{h}</text>')
body = (
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="white" stroke="black" stroke-width="3"/>'
+ "".join(ticks) + "".join(numerals)
+ hand(hour_angle, 45, 6, "black")
+ hand(minute_angle, 70, 4, "black")
+ f'<circle cx="{cx}" cy="{cy}" r="4" fill="black"/>'
)
return body
COIN_INFO = {5: ("#c0c0c0", ""), 10: ("#d9d9d9", "10¢"), 25: ("#b8b8b8", "25¢")}
COIN_NAMES = {5: "nickel", 10: "dime", 25: "quarter"}
def coins_svg(coin_values):
n = len(coin_values)
spacing = 200 // (n + 1)
parts = []
for i, v in enumerate(coin_values):
cx = spacing * (i + 1)
color, label = COIN_INFO[v]
radius = 30 if v == 25 else (26 if v == 10 else 28)
parts.append(
f'<circle cx="{cx}" cy="100" r="{radius}" fill="{color}" stroke="#444" stroke-width="2"/>'
f'<text x="{cx}" y="105" font-size="14" text-anchor="middle">{label}</text>'
)
return "".join(parts)
def coin_list_words(coin_values):
names = [COIN_NAMES[v] for v in coin_values]
if len(names) == 1:
return f"a {names[0]}"
if len(names) == 2:
return f"a {names[0]} and a {names[1]}"
return ", ".join(f"a {n}" for n in names[:-1]) + f", and a {names[-1]}"
# ─── Per-deck generators ─────────────────────────────────────────────────────
def gen_shapes():
deck_key = "shapes"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Shapes: Polygons & Quadrilaterals")
media_files = []
jobs = []
for n in range(3, 11):
jobs.append(("polygon_image", n))
jobs.append(("polygon_sides", n))
for qname in QUADRILATERALS:
jobs.append(("quad_image", qname))
random.seed(60)
random.shuffle(jobs)
for kind, val in jobs:
if kind == "polygon_image":
n = val
name = POLYGON_NAMES[n]
svg_path = save_svg(polygon_svg(regular_polygon_points(n)), f"poly_{n}.svg")
media_files.append(svg_path)
add_note(deck, model, f'<img src="poly_{n}.svg">', name,
"What shape is this?", name, media_files, f"shape_img_{n}")
elif kind == "polygon_sides":
n = val
name = POLYGON_NAMES[n]
add_text_note(deck, model, name.capitalize(), str(n),
f"How many sides does a {name} have?", num2words(n),
media_files, f"shape_sides_{n}")
else:
qname = val
svg_path = save_svg(polygon_svg(QUADRILATERALS[qname]), f"quad_{qname}.svg")
media_files.append(svg_path)
add_note(deck, model, f'<img src="quad_{qname}.svg">', qname,
"What shape is this?", qname, media_files, f"shape_quad_{qname}")
return deck, media_files, len(jobs)
def gen_clocks():
deck_key = "clocks"
model_id, model = build_model(deck_key)
deck = build_deck(deck_key, "Telling Time: Analog Clocks")
media_files = []
times = [(h, m) for h in range(1, 13) for m in range(0, 60, 5)]
random.seed(61)
random.shuffle(times)
# The question prompt ("What time is it?") is identical for every card —
# generate it once instead of 144 times.
shared_qfile = "q_clock_prompt.wav"
piper_tts("What time is it?", os.path.join(MEDIA_DIR, shared_qfile))
media_files.append(os.path.join(MEDIA_DIR, shared_qfile))
for hour, minute in times:
svg_path = save_svg(clock_svg(hour, minute), f"clock_{hour}_{minute:02d}.svg")
media_files.append(svg_path)
answer = f"{hour}:{minute:02d}"
afile = f"a_clock_{hour}_{minute:02d}.wav"
apath = os.path.join(MEDIA_DIR, afile)
piper_tts(time_words(hour, minute), apath)
media_files.append(apath)
deck.add_note(genanki.Note(
model=model,
fields=[f'<img src="clock_{hour}_{minute:02d}.svg">', answer,
f"[sound:{shared_qfile}]", f"[sound:{afile}]"],
))
return deck, media_files, len(times)
def gen_currency():
deck_key = "currency"
model_id, model = build_model(deck_key)
# Deliberately nickel/dime/quarter only, no pennies — pennies are barely
# used day to day at this point, and skipping them keeps every total a
# multiple of 5 cents, which is a cleaner first pass at coin counting.
deck = build_deck(deck_key, "Counting Coins (nickels, dimes, quarters)")
media_files = []
denoms = [5, 10, 25]
combos = set()
for count in range(1, 5):
def rec(remaining, current):
if remaining == 0:
combos.add(tuple(sorted(current)))
return
for d in denoms:
if not current or d >= current[-1]:
rec(remaining - 1, current + [d])
rec(count, [])
combos = sorted(combos)
random.seed(62)
random.shuffle(combos)
for coin_values in combos:
total = sum(coin_values)
svg_path = save_svg(coins_svg(list(coin_values)), f"coins_{'_'.join(map(str, coin_values))}.svg")
media_files.append(svg_path)
qtext = f"How much money is {coin_list_words(list(coin_values))}?"
atext = f"{num2words(total)} cents"
add_note(deck, model, f'<img src="coins_{"_".join(map(str, coin_values))}.svg">',
str(total), qtext, atext, media_files, f"coins_{'_'.join(map(str, coin_values))}")
return deck, media_files, len(combos)
if args.deck == "shapes":
deck_key = "shapes"
elif args.deck == "clocks":
deck_key = "clocks"
else:
deck_key = "currency"
MEDIA_DIR = os.path.join(SCRATCH, f"media_{deck_key}_{args.voice}")
os.makedirs(MEDIA_DIR, exist_ok=True)
GENERATORS = {"shapes": gen_shapes, "clocks": gen_clocks, "currency": gen_currency}
deck, media_files, count = GENERATORS[args.deck]()
package = genanki.Package(deck)
package.media_files = media_files
out_path = os.path.join(SCRATCH, f"{deck_key}_{args.voice}.apkg")
package.write_to_file(out_path)
size_mb = os.path.getsize(out_path) / (1024 * 1024)
print(f"\nDone: {out_path} ({size_mb:.1f} MB, {count} cards, {len(media_files)} media files)")
+47 -15
View File
@@ -277,17 +277,34 @@ sync_github_to_gitea() {
# old commit indefinitely, with no error at any step. Also no longer
# silencing stderr: a real auth/network failure should be visible in the
# log, not just "Failed to fetch" with no reason why.
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
if [[ -d "$local_path" ]]; then
info "Fetching $full_name from GitHub..."
git -C "$local_path" fetch origin '+refs/heads/*:refs/heads/*' --prune --quiet || {
err "Failed to fetch $full_name"; return 1; }
else
info "Cloning $full_name from GitHub..."
mkdir -p "$(dirname "$local_path")"
local auth_url="${clone_url/https:\/\//https:\/\/$GITHUB_TOKEN@}"
git clone --bare --quiet "$auth_url" "$local_path" || {
err "Failed to clone $full_name"; return 1; }
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
git -C "$local_path" remote add origin "$auth_url"
fi
# Explicit heads+tags refspec on BOTH the initial clone and every later
# fetch, not `git clone --bare` (which pulls every ref the remote
# advertises, refs/pull/*/head included) — GitHub exposes PR refs over
# the same smart-HTTP endpoint a plain bare clone reads from, and those
# live in a namespace Gitea's own PR system reserves for itself. A later
# `git push --mirror` (pushes every local ref verbatim) then gets
# rejected by Gitea's server-side hook — confirmed live: "hook declined
# to update refs/pull/1/head". Scoping fetch AND push to heads/tags only
# avoids ever touching that namespace in either direction.
git -C "$local_path" fetch origin \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' \
--prune --quiet || { err "Failed to fetch $full_name"; return 1; }
# Self-heals a repo synced before this fix — a stray refs/pull/* (or any
# other non-heads/tags ref) an earlier run's unscoped `clone --bare`
# already pulled in would otherwise keep tripping the same Gitea hook on
# every sync from here on, with no other way to clear it.
git -C "$local_path" for-each-ref --format='%(refname)' \
'refs/pull/*' 'refs/merge-requests/*' 'refs/changes/*' \
| xargs -r -n1 git -C "$local_path" update-ref -d
# Ensure repo exists on Gitea
local gitea_check
@@ -299,12 +316,17 @@ sync_github_to_gitea() {
>/dev/null || { err "Failed to create $repo_name on Gitea"; return 1; }
fi
# Push to Gitea
# Push to Gitea — same explicit heads+tags scoping as the fetch above,
# not --mirror (which would push refs/pull/* etc. verbatim and hit the
# same rejected-hook failure this whole fix is for). --prune still makes
# Gitea's heads/tags a true mirror of GitHub's (deletes ones GitHub no
# longer has), just without ever touching reserved ref namespaces.
local gitea_push_url="${GITEA_URL/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
gitea_push_url="${gitea_push_url/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
gitea_push_url="$gitea_push_url/$GITEA_USER/$repo_name.git"
git -C "$local_path" push --mirror "$gitea_push_url" --quiet || {
git -C "$local_path" push --prune --quiet "$gitea_push_url" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {
err "Failed to push $full_name to Gitea"; return 1; }
ok "GitHub → Gitea: $full_name"
_log "PULL $full_name OK"
@@ -320,18 +342,26 @@ sync_gitea_to_github() {
local gitea_auth_url="${clone_url/https:\/\//https:\/\/$GITEA_USER:$GITEA_TOKEN@}"
gitea_auth_url="${gitea_auth_url/http:\/\//http:\/\/$GITEA_USER:$GITEA_TOKEN@}"
# See the matching comment in sync_github_to_gitea() above — same
# explicit-refspec, visible-stderr fix, same reason.
# See the matching comment in sync_github_to_gitea() above — same reason
# applies in reverse: Gitea also exposes PR refs (refs/pull/*/head) over
# its git smart-HTTP endpoint, and GitHub rejects direct pushes to that
# same reserved namespace just as Gitea's hook does. Explicit heads+tags
# refspec on the initial clone too, not `git clone --bare`.
if [[ -d "$local_path" ]]; then
info "Fetching $full_name from Gitea..."
git -C "$local_path" fetch origin '+refs/heads/*:refs/heads/*' --prune --quiet || {
err "Failed to fetch $full_name from Gitea"; return 1; }
else
info "Cloning $full_name from Gitea..."
mkdir -p "$(dirname "$local_path")"
git clone --bare --quiet "$gitea_auth_url" "$local_path" || {
err "Failed to clone $full_name from Gitea"; return 1; }
git init --bare --quiet "$local_path" || { err "Failed to init $full_name"; return 1; }
git -C "$local_path" remote add origin "$gitea_auth_url"
fi
git -C "$local_path" fetch origin \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' \
--prune --quiet || { err "Failed to fetch $full_name from Gitea"; return 1; }
# Self-heals a repo synced before this fix — see the matching comment above.
git -C "$local_path" for-each-ref --format='%(refname)' \
'refs/pull/*' 'refs/merge-requests/*' 'refs/changes/*' \
| xargs -r -n1 git -C "$local_path" update-ref -d
# Ensure repo exists on GitHub
local gh_check
@@ -343,9 +373,11 @@ sync_gitea_to_github() {
>/dev/null || { err "Failed to create $repo_name on GitHub"; return 1; }
fi
# Push to GitHub
# Push to GitHub — explicit heads+tags scoping, not --mirror. Same
# reasoning as the Gitea push above.
local github_push_url="https://$GITHUB_TOKEN@github.com/$GITHUB_USER/$repo_name.git"
git -C "$local_path" push --mirror "$github_push_url" --quiet || {
git -C "$local_path" push --prune --quiet "$github_push_url" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' || {
err "Failed to push $full_name to GitHub"; return 1; }
ok "Gitea → GitHub: $full_name"
_log "PUSH $full_name OK"