# Teletype Games Monorepo for the Teletype Games portal: a Vue 3 frontend, a Rails 8 API host app, and the reusable **WarpEngine** software-catalog engine. ## Project structure ``` apps/ frontend/ # Vue 3 + Vite + TypeScript + Tailwind SPA api/ # Rails 8 host app: TTG-specific API + ActiveAdmin shell libs/ ruby/warp_engine/ # WarpEngine: mountable Rails engine (catalog, updater, admin resources) ``` The API is split in two layers: - **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models (softwares, releases, release assets, software-image links, platform links, download stats), the CI-callable `/build/*` publishing endpoints, the public read-only JSON API (`/api/software*`, `/api/builds*`, `/api/download`, `/file/*`) and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md). - **The host app** (`apps/api`) owns everything TTG-specific: members, events, the image library (`Image`, `/api/image/:id`, the admin page — the engine only links to it through an adapter), the store registry, wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine as a path gem and mounts it at `/`. ## Languages The site is bilingual (English and Hungarian) and so is the wiki behind it. `Locales` (`lib/locales.rb`) is the one list of supported codes; anything else falls back to `en`. The wiki serves English unprefixed and Hungarian under `/hu`, so `Wiki::Pages` builds the language into the request path rather than into a query parameter: ``` GET /api/wiki/pages?tag=howto&lang=hu -> https://wiki.teletypegames.org/hu/custom/pages.json?tag=howto GET /api/wiki/pages?tag=howto -> https://wiki.teletypegames.org/custom/pages.json?tag=howto ``` Each page in the response carries a `locale` saying which language its body is **actually** written in. That differs from the requested `lang` for the wiki pages that exist only in Hungarian: they are served as-is rather than 404ing, and a client can label them. The RSS feeds take the same `lang` parameter (`/api/rss/blog?lang=hu`), which sets the channel language and picks the wiki language and the feed's own strings from `config/locales/`. The frontend passes the interface language on every wiki call and rebuilds its outbound wiki links with `wikiUrl()`, so switching EN/HU re-reads the catalog and points the links at the matching wiki pages. ## The store registry `GET /api/stores` lists the stores a client can install from. The desktop graphical client reads it on first run, which is why it is **public**: a client has nobody to log in as. ```json [ { "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null } ] ``` A `Store` row has two required fields — `name` and `catalog_url` — plus an **optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**; `db/seeds.rb` creates our own. **A store does not need a repository.** The store engine's own defaults already cover the host-to-asset mapping, the install modes, the platforms and the behaviour; what they cannot know is identity — a slug, a name and a catalog — and that is what this row carries. With no repository the client derives the slug from the catalog host, writes a small config and installs. Given one, it reads that repository's `config.json` and points it at `catalog_url`, and that file remains the authority on how the store behaves; a repository without a config file is treated as no repository at all. Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry above is the host's own endpoint and does not, because it is not part of the engine. This lives in the host app **on purpose, not in WarpEngine**. The engine serves one catalog and has no business knowing which stores exist for it; who ships a store for a catalog is a property of the site. | | | |---|---| | Model | `apps/api/app/models/store.rb` | | Endpoint | `apps/api/app/controllers/api/stores_controller.rb` | | Admin | `apps/api/app/admin/stores.rb` | | Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) | ## Development environment ```bash docker compose up -d ``` | Service | URL | |---|---| | Frontend | `http://${WEBAPP_DOMAIN}` | | API | `http://${WEBAPP_DOMAIN}/api` | | Admin | `http://${WEBAPP_DOMAIN}/admin` | | API docs | `http://${WEBAPP_DOMAIN}/api/swagger` | The api image is built from the repo root (so the `libs/` path gems are visible during `bundle install`) and mounts `./libs` at runtime. After changing the compose file or the Gemfile, rebuild with `docker compose up -d --build api`. ## Testing ```bash make api-test ``` Runs the host suite (`apps/api`, `softwares_test` DB), the WarpEngine suite (dummy app, `warp_engine_test` DB) and a production-mode `zeitwerk:check`. Individually: ```bash docker exec -e RAILS_ENV=test api bundle exec rspec # host docker exec -w /libs/ruby/warp_engine -e RAILS_ENV=test api bundle exec rspec # engine ``` JSON contract baselines for `/api/software` and `/api/builds` live in `apps/api/spec/snapshots/` — diff against them after refactors. ## Linting ### Backend (RuboCop) ```bash docker compose run --rm --no-deps api bundle exec rubocop # host app docker compose run --rm --no-deps api bundle exec rubocop -A # host app, autofix docker exec -w /libs/ruby/warp_engine api \ env BUNDLE_GEMFILE=/app/Gemfile bundle exec rubocop # engine ``` Config: `apps/api/.rubocop.yml` and `libs/ruby/warp_engine/.rubocop.yml` (both rubocop-rails-omakase). Both are green, and CI keeps them that way. ### Frontend (ESLint) ```bash docker compose run --rm --no-deps frontend npm run lint # check docker compose run --rm --no-deps frontend npm run lint:fix # autofix ``` Config: `apps/frontend/eslint.config.js` (ESLint 9 flat config, Vue + TypeScript) ## Pipelines Woodpecker reads every file in `.woodpecker/` as its own workflow, each with its own trigger. ### `.woodpecker/warp_engine.yaml` — the gem Runs when a push to master touches `libs/ruby/warp_engine/**`, on a manual run, and on a `warp_engine-v*` tag: | Step | What it guards | |---|---| | `version-bumped` | the engine changed but `WarpEngine::VERSION` did not — fails first, before anything else runs | | `tag-matches-version` | tag events only: `warp_engine-v0.9.1` must find `VERSION = "0.9.1"` | | `rubocop` | `libs/ruby/warp_engine` against its own `.rubocop.yml` | | `test-engine` | the engine suite (dummy app, `warp_engine_test` DB) | | `split-mirror` | pushes the subtree split to `engines/warp_engine` (master pushes only) | | `publish-gem` | `gem push` to the Forgejo registry (tags only) | Nothing is mirrored or published until the version check, RuboCop and the suite have all passed. The mirror repository deliberately has no CI of its own: the subtree split overwrites it on every run. ### `.woodpecker/deploy.yaml` — the deploy Runs on every push to master (and manually): `rubocop` → `test-host` (host suite + a production-mode `zeitwerk:check`) → `pull` → `restart`. The `pull` and `restart` steps reach the server through the host Docker socket rather than SSH — the `woodpecker-agent` runs in the same stack as `api` and `frontend`. Two things this depends on: - The stack directory is bind-mounted **at the same path** it has on the host (`/srv/stacks/teletype-games`), so the compose file's relative bind mounts (`./data`, `./apps`) still resolve where they did. - Woodpecker only allows step volumes on a **trusted** repository: enable *Trusted → Volumes* in the repo settings (admin only), or the deploy steps are rejected. `pull` fails if the server checkout is not on `master` (a detached HEAD would make the pull look successful while the running code never moves). `restart` installs dependencies, runs `db:migrate` and only then restarts — and it touches only `api` and `frontend`: restarting `woodpecker-server` would cut off the very pipeline doing the deploy. `db:migrate` in the host app covers the engine too — WarpEngine appends its own `db/migrate` to the host's migration paths. A failing migration stops the deploy before the restart, so the old code keeps running. ### Gem cache Both workflows install gems into a host directory that survives runs: | Workflow | Host path | `BUNDLE_PATH` | |---|---|---| | `warp_engine.yaml` | `/srv/ci-cache/bundle/warp_engine-ruby3.2` | `/cache/bundle` | | `deploy.yaml` | `/srv/ci-cache/bundle/api-ruby3.3` | `/cache/bundle` | Cold install is ~35 s, warm ~6 s (113 MB of gems). The directory is keyed by Ruby version because native extensions (mysql2) are built against one ABI; the `flock` around `bundle install` keeps two concurrent pipelines from writing the same directory at once. Docker creates the directories on first run — to drop the cache, delete them. This needs the same *Trusted → Volumes* flag the deploy does. ### Why a MySQL service and not a stub Two thirds of the engine suite (22 of 36 spec files, all 11 request specs) create rows and assert on what comes back, and both `schema.rb` files are MySQL-shaped (`charset: utf8mb4`, `collation: utf8mb4_0900_ai_ci`, unsigned keys). A null adapter answers every query with nothing, and SQLite would test a database we do not run. The service container costs a start, not a download — the agent's Docker daemon already has the image. Secret used by both workflows: `forge_token` — a Forgejo token with repository read/write and package:write. ## Version bump hook The engine's version rule is enforced twice, by the same script: ```bash git config core.hooksPath .githooks # once per clone ``` `.githooks/pre-commit` runs `script/warp_engine_version_check.rb --staged`: a commit that touches `libs/ruby/warp_engine/**` must also raise `WarpEngine::VERSION` above the one in `HEAD`. The pipeline runs the same script in `--range` mode over the pushed commits, so nothing slips through a `--no-verify`. Escape hatch when a bump genuinely does not belong: `SKIP_WARP_ENGINE_VERSION_CHECK=1 git commit ...`