feedBack/lib/tailwind_rebuild.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

202 lines
7.3 KiB
Python

"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
Core's committed (and image-baked) stylesheet is built scanning only the
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
— ships Tailwind classes the sheet never saw, so it renders unstyled. The
Play CDN's runtime JIT that used to cover this was removed (feedBack#411),
so we rebuild the sheet ourselves with node + the pinned ``tailwindcss``,
scanning the baked-in plugins *and* the user plugins dir.
Best-effort: a logged no-op (returns ``False``) when the toolchain or inputs
are absent — e.g. a native dev run with no node, or a desktop bundle that
already baked a complete sheet — so plugin install / startup never hard-fails
on a missing optional engine.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
from env_compat import getenv_compat
log = logging.getLogger("feedBack.tailwind")
# Pin matches scripts/build-tailwind.sh and the Dockerfile build stage so every
# sheet — committed, image-baked, and runtime-regenerated — comes from the same
# Tailwind 3.x.
_TAILWIND_VERSION = "3.4.19"
# Serialize rebuilds: concurrent installs (or install racing the startup scan)
# must not run the CLI against the same output file at once.
_lock = threading.Lock()
# Set by a trigger that arrives while a rebuild is already running, so the
# in-flight build re-runs once more to pick up the newer plugin set instead of
# every concurrent trigger stacking its own redundant build.
_rerun = threading.Event()
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
# grandparent.
APP_DIR = Path(__file__).resolve().parent.parent
def _user_plugins_dir() -> Path | None:
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
if not raw:
return None
p = Path(raw)
return p if p.is_dir() else None
def user_plugin_count() -> int:
"""Number of installed plugins in the runtime user plugins dir (0 if unset).
Counts only directories that contain a ``plugin.json`` — so stray caches/tmp
dirs (which have none) don't trigger rebuilds.
"""
d = _user_plugins_dir()
if not d:
return 0
return sum(1 for p in d.iterdir() if p.is_dir() and (p / "plugin.json").is_file())
def _tailwind_cmd() -> list[str] | None:
"""Prefer a globally-installed ``tailwindcss`` (offline, no fetch); fall back
to ``npx`` which resolves/fetches the pinned version on demand."""
exe = shutil.which("tailwindcss")
if exe:
return [exe]
npx = shutil.which("npx")
if npx:
return [npx, "-y", f"tailwindcss@{_TAILWIND_VERSION}"]
return None
def can_rebuild() -> bool:
return (
_tailwind_cmd() is not None
and (APP_DIR / "tailwind.config.js").is_file()
and (APP_DIR / "static" / "_tailwind.src.css").is_file()
)
def _write_runtime_config(tmpdir: Path) -> Path:
"""Wrapper config that reuses the base theme/safelist/exclusions but widens
``content`` to absolute paths covering the user plugins dir as well."""
base_cfg = APP_DIR / "tailwind.config.js"
# Use forward-slash (POSIX) globs/paths: Tailwind's fast-glob matcher needs
# forward slashes even on Windows, and node `require()` accepts them too.
content = [
(APP_DIR / "static" / "**" / "*.{html,js}").as_posix(),
(APP_DIR / "plugins" / "**" / "*.{js,html}").as_posix(),
]
user = _user_plugins_dir()
if user:
content.append((user / "**" / "*.{js,html}").as_posix())
# Exclude a user-installed highway_3d too (it ships its own sheet).
content.append("!" + (user / "highway_3d" / "**").as_posix())
# highway_3d ships its own sheet via the `styles` capability — keep it out
# of the core sheet, mirroring tailwind.config.js.
content.append("!" + (APP_DIR / "plugins" / "highway_3d" / "**").as_posix())
cfg = tmpdir / "tailwind.runtime.config.js"
cfg_js = (
"const base = require({base});\n"
"base.content = {content};\n"
"module.exports = base;\n"
).format(
base=json.dumps(base_cfg.as_posix()),
content=json.dumps(content),
)
cfg.write_text(cfg_js)
return cfg
def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
"""Run one Tailwind build over the current plugin set. Never raises."""
with tempfile.TemporaryDirectory() as td:
cfg = _write_runtime_config(Path(td))
# Stage the output next to the live sheet so the final swap is an
# atomic same-filesystem os.replace (a reader never sees a partial).
staged = out.with_name(f".tailwind.min.css.{os.getpid()}.tmp")
cmd = cmd_prefix + [
"-c", str(cfg),
"-i", str(src),
"-o", str(staged),
"--minify",
]
try:
subprocess.run(
cmd, check=True, capture_output=True, text=True,
cwd=str(APP_DIR), timeout=120,
)
os.replace(staged, out)
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
stderr = (getattr(e, "stderr", "") or "")[-500:]
log.warning("tailwind rebuild failed: %s", stderr)
return False
except Exception:
log.exception("tailwind rebuild errored")
return False
finally:
if staged.exists():
try:
staged.unlink()
except OSError:
pass
def rebuild(reason: str = "") -> bool:
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
Never raises — callers treat CSS freshness as best-effort. Concurrent
triggers are coalesced: only one build runs at a time, and triggers that
arrive mid-build cause a single extra rerun rather than stacking builds.
"""
tag = f" [{reason}]" if reason else ""
cmd_prefix = _tailwind_cmd()
if cmd_prefix is None or not can_rebuild():
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
return False
out = APP_DIR / "static" / "tailwind.min.css"
src = APP_DIR / "static" / "_tailwind.src.css"
# If a rebuild is already running, flag a rerun and return instead of
# queueing a redundant build behind it.
if not _lock.acquire(blocking=False):
_rerun.set()
log.info("tailwind rebuild already running — coalesced%s", tag)
return False
ok = False
try:
while True:
_rerun.clear()
ok = _run_build(cmd_prefix, out, src)
# A trigger arrived while we were building — run once more to pick
# up the newer plugin set, then stop.
if not _rerun.is_set():
break
finally:
_lock.release()
if not ok:
return False
# Guard the stat so the "never raises" contract holds even if the freshly
# written sheet is somehow not stat-able (odd FS / external cleanup).
try:
size = out.stat().st_size
except OSError:
size = -1
log.info("tailwind rebuilt over installed plugins%s (%d bytes)", tag, size)
return True