`api/packages/tools/rubygems` was right until the org reshuffle moved the engine
to `engines`, and it has been copy-pasteable-but-broken since. The gemspec's
allowed_push_host and the CI's `gem push --host` both say `engines`; only the
README did not.
While there: pin the example to `~> 0.5` and say why the source is block-scoped.
A second *global* source leaves Bundler unable to say which gem came from where.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The access policy asks who the caller is, and until now only a bearer token
could answer. That is what a desktop client carries — but a person clicking a
download link on the site carries a session instead, and the engine has no idea
what a session is. So a host that gated its catalog found its own signed-in
visitors refused at /api/download, which is a regression the shadowed route used
to hide.
c.subject_resolver is a callable taking the Rack request and returning the
host's subject: `->(request) { request.env["warden"]&.user }` for a Devise app.
Unset — every deployment today — a non-bearer request stays anonymous, exactly
as before. A resolver that raises is logged and treated as anonymous, because a
broken one turning every read into a 500 is worse than an anonymous request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A desktop client reading /api/software had no way to learn that a title costs
money. There was nothing in the response to say so, no way to sign in, and no
way to be told "you do not own this" — so a store with paid titles could only
hand the client a 403 at download time and let it guess why.
The fix belongs here rather than in the client. A client serves more than one
store, so anything it knows about a particular one has to arrive from that
store's own API; a rule compiled into the client is a rule that breaks every
other catalog it reads. Three seams, each following the storage adapter's
shape — documented contract, default that is byte for byte the old behaviour,
one config key to replace it:
- **access policy** — visible_software_scope / access_for / authorize_download.
Every catalog entry now carries an `access` block (gated, entitled, price,
purchaseUrl, webUrl) and both /api/download and /file/* ask before serving.
The vocabulary is deliberately generic: a word from one host's domain would
make every client that reads it specific to that host.
- **client sign-in** — the device authorization grant (RFC 8628), over the
host's own user model. The approval page stays the host's, because approving
needs a session and HTML. Tokens are ApplicationTokens with a `catalog`
scope, so publishing and reading stay separable.
- **service descriptor** — GET /api/service says what this deployment is and
whether it has a sign-in at all, which is how a client stops guessing.
With no policy and no subject class configured — every deployment today — the
API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and
the 187 pre-existing examples pass untouched.
A policy that raises is treated as a refusal, not permission. An artifact
served because the gatekeeper crashed is the one failure mode this must not
have, so a broken policy empties the catalog and denies the download.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both extras go. `config` was added this morning on the idea that the registry should
say how each store behaves; that was wrong. The configuration is fixed per installed
client — the client carries its own store engine and knows its own machine — so a copy
here was a second authority over decisions the client had already made correctly,
including which directories it may delete from. Keeping two stores on one machine apart
is a subfolder, and the client derives that itself.
`store_repository_url` goes with it. The store engines it pointed at no longer exist, and
a URL nobody follows is a URL that goes stale.
The public stores page loses its desktop card for the same reason: it advertised a
`curl … | sh` for a repository that is gone, and an ordinary computer is served by the
app in the section above it. `/desktop` now lands on that app rather than on a device
tab, so the old URL still means what someone typing it wants.
Unrelated but in the way: the dead `engines` list in that page has been failing
`vue-tsc` on master, so the frontend could not be built to check any of this. Removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A store's `config.json` lived in a repository the desktop client fetched over HTTP,
which made a store's behaviour depend on a second thing existing and staying
reachable. The registry already answers what a store *is*; it now answers how it
behaves too, in the same shape that file had, so this record is the one source of
truth and a store can be configured from the admin alone.
`storeRepositoryUrl` stays, demoted to a pointer for a person — where the store's
own repository is, when it has one. Clients released before this field still fetch a
`config.json` from it, so nothing has to move at once.
The column is nullable because a store that configures nothing is still a store: the
client falls back to the engine's built-in defaults, which need only a name and a
catalog. The admin edits it as JSON text through a pair of accessors, so the column
holds real JSON and invalid input comes back with the text kept and a message rather
than a 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ActiveAdmin.register WarpEngine::Pipeline` declared no `permit_params`, so every edit
handed unpermitted attributes to the model and Rails raised ForbiddenAttributesError. That
is not new: the form has been unable to save for as long as it has existed. My flash
message on `update` sat at the top of the traceback and made it look like the cause, which
it was not — and it is gone anyway, because overriding an ActiveAdmin action to say
something is a poor trade for what it can break. The move is written to the log instead.
Adding a spec that would have caught it, in the host app, because that is where the
ActiveAdmin instance lives: it signs in, PUTs the form, and checks both that the record
saves and that the software link moves off the pipeline that had it. Driven the same way
by hand against the development database first — 302, the link moved, the previous holder
left without one.
The engine's other admin resources were checked for the same omission: downloads and
releases are read-only and the file manager posts to its own routes, so pipelines was the
only one affected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The version header was set from WarpEngine::VERSION_HEADER, a constant introduced in the
same commit. The deploy that followed ran these controllers with an older `lib/`, so the
before_action raised NameError on every request and every engine endpoint answered 500 —
the catalog, the images, the file server and the config extension Woodpecker calls, which
is how it surfaced: a pipeline could no longer fetch its own configuration.
The controller now spells the header name out. A response header is not worth a dependency
that can take the API down when one half of a deploy is older than the other, and the
constant remains the documented name with a spec holding the two in step.
Confirmed in production mode against the same code that failed: 200 with the header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every WarpEngine API response now carries `WarpEngine-Version`, so a client can branch on
the engine's age without a round trip to ask. Set in a before_action rather than after:
`rescue_from` never reaches an after_action, and a client needs the version most when
something came back wrong. The name lives in `WarpEngine::VERSION_HEADER`. The host's own
endpoints — the store registry — do not carry it, because they are not the engine.
A software has one pipeline, and the newest assignment now wins. Two pipelines pointing at
the same software was not an error the database caught; it was a link that silently did
nothing, with the software still showing whichever row came first. Assigning a software
another pipeline holds therefore moves it, the admin says which pipeline it was taken
from, and `Pipeline#software_taken_from` carries that for anything else that cares.
Deliberately a callback and not a unique index: rows here are soft-deleted, and a unique
index counts deleted rows, so a pipeline removed last year would block its software from
ever being linked again.
The engine is 0.4.0. The site's /stores page and its screenshot follow the client's new
name, and the shot is a fresh one showing the greyed-out titles the client now lists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client only ever took identity from a store repository — a slug, a name, a catalog —
and the store engine's own defaults cover everything else: the host-to-asset mapping, the
install modes, the platforms, the behaviour. So `store_repository_url` is now optional:
nullable in the schema, no presence validation, the format check only when a value is
given, and the serializer answers null rather than an empty string, because the client
branches on its absence.
Adding a store is therefore a row with two fields filled in. Given a repository the
client still reads its config.json, and that file remains the authority on how the store
behaves — the admin form and the endpoint's documentation say so.
The frontend's /stores page gains a section of its own for the graphical client on the
desktop tab: what it does, that it sets the store up itself, that it is the way in on
Windows where `curl … | sh` does not exist, and links to the releases, the repository and
the documentation — now under stores/warp-engine-client, which is where that repository
lives after the rename.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop graphical client had our store's config URL compiled into it. That is
backwards: which stores exist for a catalog is something the site knows, and a
client should be able to ask. `GET /api/stores` answers, and the client picks from
what comes back.
A `Store` row is three fields — name, catalog_url, store_repository_url — with an
ActiveAdmin panel, a Blueprinter serializer in the catalog's camelCase, and a
service+controller pair following the events and members shape. `db/seeds.rb`
creates our own record, so a fresh database serves a working registry.
The endpoint is **public**, which is the point: the client runs on someone's
laptop before any store exists and has nobody to log in as. It exposes three URLs
that are public anyway.
This deliberately does not live in WarpEngine. The engine serves one catalog and
has no business knowing who ships stores for it; a registry of stores is a
property of this site, not of the catalog software. Anyone mounting WarpEngine can
keep their own list, or none.
16 model and controller examples pass. Worth noting for the next person who runs
them: the specs need RAILS_ENV=test, as the README says — without it rspec runs in
the development environment, host authorization rejects Rack::Test's hostname, and
every request spec fails with a 403 and an HTML body that looks nothing like a
routing problem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop store now has a window, and it is the only one of the three that
does — so the download link and the sentence explaining it appear on that tab
alone. On Windows this is the way in, since the shell installer needs a shell
the platform does not have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page had two device tabs, both for hardware most visitors do not own. The
desktop store makes the third one the likeliest answer, so "This computer" is
added and selected by default, and /desktop redirects to it the way /batocera and
/retroarch already do.
Device names now come from i18n rather than the component: "Batocera" and
"RetroArch" are product names either way, but "This computer" has to be
translatable. Every key the page uses was checked to exist in both locales.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Batocera store had /batocera to itself. A RetroArch store now serves the same
cartridges on every other machine, and copying the page would have duplicated
everything the two have in common — the framing, the platform table, the engine
links — so they share one page and a Batocera/RetroArch chooser. Only the install
command, the CLI and the uninstall line change with the tab.
/batocera and /retroarch both redirect here with the matching tab preselected,
so old links keep working and the name someone guesses after reading "RetroArch
store" lands somewhere useful. `?device=` makes a link to either half shareable.
The page also documents uninstalling, which it never did, and the five command
blocks share one CommandBlock component instead of repeating the copy button.
The i18n `batocera` block becomes `stores` in both locales; every key the page
uses was checked to exist in both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine's 180 specs were never run by CI: the mirror repo is
generated by a subtree split, so a pipeline committed there would be
overwritten on the next sync, and the monorepo's own pipeline only
mirrored and published. A broken engine could reach the gem registry.
The test now gates both — it runs on the same trigger the mirror does
(a change under libs/ruby/warp_engine), and the steps after it only run
if it passes. The specs need MySQL, hence the service, and a prepared
test database: on a fresh database maintain_test_schema! reports the
engine's own migrations as pending instead of loading the schema.
Verified by running the step as written in a clean ruby:3.2 container
against a fresh mysql:8 — 180 examples, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The step assumed one project shape: plain sources concatenated with cat
and Phaser pulled from a CDN. trickster-tiles is the other shape — Vite
plus TypeScript, built with `tsc && vite build` — so `cat src/*.js`
found nothing to bundle and the build failed. The earlier guard on the
syntax check fixed only the first symptom of that mismatch.
A project with a build script now runs npm ci && npm run build and is
packaged from its own dist/, which already contains index.html and the
hashed assets. Plain projects keep the existing path untouched.
The bundler's base has to be relative, since games are served out of
/file/<name>-<version>/; the step fails loudly if the build leaves no
dist/index.html rather than shipping an empty zip.
Verified in the phaser-builder image against trickster-tiles: a 344 kB
package with index.html at the root and ./assets/ references.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The org reorganization moved the toolchain repos to build/ but left
their container images in internal/: a package namespace does not
travel with the repo and gets no redirect, which is the only reason
the internal org was still alive.
All eight images now live under build/ — the six unchanged ones copied
layer-for-layer, the Ebitengine and Bevy ones rebuilt for the ARM
target. The internal org can be emptied.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batocera and the ES-family distributions run on ARM as much as on
x86_64 — Raspberry Pi, Odroid, the retro handhelds — and a linux_x64
binary installs there but will not start. There was no Linux ARM asset
kind at all: KINDS had linux_x86 and linux_x64 and mac_arm64, but
nothing for 64-bit ARM Linux.
Registering the kind is deliberately separate from producing it: a
platform service only includes BuildLinuxArm64 once its pipeline builds
the artifact, otherwise /api/builds would report it missing for every
release. Hence Ebitengine and Bevy only. Godot needs a Linux arm64
export preset in each game repo first; LÖVE fuses an upstream AppImage
that ships x86_64 only; TIC-80's export command has no ARM target.
Ebitengine needs cgo on Linux, so binary_build gained a cross-compiler
argument — and unsets CC for native targets, otherwise a build after
the ARM one silently picks up the cross gcc.
Verified by cross-compiling both demos in the rebuilt images: each
produced a genuine AArch64 ELF (e_machine 183), not a silent fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The push prompted for credentials and then died with "404 page not
found". Both are the same fault: RubyGems normalizes the keys in
~/.gem/credentials — dots become __ and a trailing slash is appended —
so a key written as the host URL can never match the --host value.
Finding no key, gem push falls back to signing in against the RubyGems
sign_in endpoint, which Gitea does not implement; that is the 404.
A named key is stored verbatim and is matched by --key, so the lookup
succeeds. Verified against Gem::ConfigFile with both formats.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The publishing pipeline was already complete — subtree split to the
mirror, gem build and push on a warp_engine-v* tag — but it had never
been triggered, and after the org reorganization three of its targets
were stale: the mirror push URL and the rubygems registry namespace
(twice).
The gemspec's allowed_push_host has to match the --host that `gem push`
receives, so it now carries the full registry URL rather than the bare
forge host, plus source and documentation links.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client was split into a reusable engine and a store definition, so
the page is now about our store: new repo and wiki links, the CLI path
the installer actually writes, and a closing section pointing at
warp-engine-batocera-store for anyone who wants a store of their own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine ships an ActiveJob based job (PipelineSyncJob), but a host's
application.rb does not necessarily require active_job/railtie - apps/api does
not. With eager loading off (development, test) nothing noticed; in production
WarpEngine::ApplicationJob blew up with "uninitialized constant
WarpEngine::ActiveJob", which is exactly what `rails zeitwerk:check` in
RAILS_ENV=production reported. An engine that ships jobs has to pull in the
framework it needs, so lib/warp_engine.rb requires the railtie.
Pre-existing on 0.1.0 as well; found while verifying that the 0.2.0 changes do
not break the portal. All three api-test steps are green now.
apps/api Gemfile.lock follows the 0.1.0 -> 0.2.0 path gem bump.
Two seams the hosts needed, both backward compatible.
Storage: artifacts are served through WarpEngine::Storage.adapter instead of
raw filesystem calls. The default :local adapter keeps the previous behaviour
byte for byte, including the path traversal guard. A host can now set
config.storage_adapter to any object answering file?/directory?/locate and
serve builds from an object store - FileService and /api/download both honour
a Location.redirect, so a signing adapter turns them into redirects.
DownloadService#create still returns an absolute path (nil when missing) for
existing callers; #locate is the new entry point that can also return a
redirect. Ingestion (upload, extraction, file manager) stays local for now.
Publish: PublishService emits ActiveSupport::Notifications
("warp_engine.publish") with platform/name/version/software/release, so hosts
can react to a new build without hanging callbacks on the models.
WarpEngine.instruments_publish? lets a host feature-detect and keep its
fallback for older engine versions.
The nav has too many items for the old spacing: smaller type, tighter
gaps, icons only on xl+ screens, and the hamburger now takes over below
lg (the desktop row did not fit between md and lg anymore).
The Hungarian engine strings drop the hyphenated loanword forms:
Engine-ek -> Motorok, Saját Engine-jeink -> Saját motorjaink.
The engines listing is a handful of curated pages, not a stream — no feed
needed. Removes the route, controller action, RssService#engines_feed and
the footer link. The WikiService#pages alias stays (blog/howtos feeds use
it).
The engine pages' repo metadata (new in the wiki pages API) drives the
Explore button and card title links, with the wiki page as fallback. The
never-deployed /engines/:slug detail page and its store/api plumbing are
gone, and the engines RSS feed links to the repos too.
Frontend: /engines index + /engines/:slug detail routes, nav menu item and
en/hu translations. Engine pages are few, so the index uses an emphasized
poster-style design (dark slate, emerald accents, numbered full-width cards
with content preview) instead of the blog/howtos layouts. Slugs are the last
wiki path segment, since engine pages live scattered in the wiki tree.
API: /api/rss/engines feed linking to the site's engine pages, and a
WikiService#pages alias for #index — RssService called the alias-less name,
so the blog and howtos feeds were raising NoMethodError.
release.UpdatedAt does not exist on the Release interface (or in the API
response) — the release dates rendered as invalid values and vue-tsc failed
the build.
examples/compose boots everything the engine's workflow assumes: mysql, a
minimal Rails host consuming the engine as a path gem, an SSH drop area
sharing the softwares volume with the app, and — behind the ci profile —
gitea plus woodpecker (agent attached to the stack network so pipeline
steps reach droparea/app by service name).
host_app doubles as a reference for a brand-new host: Gemfile, the two
initializers, apipie + engine mounts in routes.rb; on first boot the
entrypoint runs the install generator and db:prepare.
The README gains a detailed bring-up walkthrough: quickstart, publishing
a release by hand over scp + /update (verified end to end from a clean
slate), and the full gitea/woodpecker OAuth wiring.
The README is what the tools/warp_engine mirror shows: present the engine as
a standalone product installed from git or the gem registry, with the
monorepo workflow reduced to a short Development note.
A trailing newline pasted into the Woodpecker secret broke the push URL
("credential url cannot be parsed"); strip all whitespace from the token in
both the mirror and the gem-publish steps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Path-filtered push events hide the workflow for unrelated pushes and manual
restarts have no changed-files list; add event: manual so the mirror can be
triggered from the Woodpecker UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The credentials printf line contains ': ' which YAML reads as a mapping;
use a literal block scalar for that command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .woodpecker.yaml: on master pushes touching libs/ruby/warp_engine, split the
subtree and force-push it to the read-only tools/warp_engine mirror; on
warp_engine-v* tags, build and push the gem to the Forgejo rubygems registry
- engine README documents the monorepo-first workflow and the mirror
Requires a `forge_token` Woodpecker secret (repository:write + package:write).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- root README: monorepo layout with libs/, the WarpEngine/host layering,
rebuild note for the root build context, make api-test and snapshot docs
- warp_engine README translated to English
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- host apipie matcher now globs the engine controllers, so /api/docs and
/api/swagger keep documenting the catalog endpoints
- rails g warp_engine:install: initializer template + a clean
create_warp_engine_tables migration (signed bigint PKs) for new hosts;
TTG never runs it
- engine append_migrations initializer: future catalog migrations in the
engine's db/migrate run via the host's rails db:migrate
- README documents the updater contract, config surface, host expectations
(admin JS picker, apipie matcher) and the soft-delete/resurrection behavior
- make api-test runs both suites plus a production zeitwerk:check
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the 7 catalog admin files (softwares, releases, external_links,
platform_links, images, files page, downloads) move to the engine's
app/admin; the host ActiveAdmin instance loads them via
ActiveAdmin.application.load_paths (single admin, URLs unchanged)
- engine initializers: Zeitwerk ignore for app/admin (production eager load)
and load_paths + watchable_dirs registration, guarded by defined?(ActiveAdmin)
so the admin-less dummy app boots
- images admin reads image owners from WarpEngine.config.image_owners; the
host registers the Member owner in config/initializers/warp_engine.rb;
the interim ImageUsage registry is gone
- fix: SoftwareImage.distinct.pluck clashed with its order(:position)
default scope on MySQL (unscope(:order)) — introduced in phase 0, caught
by the first authenticated /admin/images smoke test
- files page: download links use the public /file/ URL instead of the raw
container path; the picker's stored path comes from config
Verified: both suites green, zeitwerk:check (production) clean, JSON
baselines intact, authenticated admin smoke test on every page incl.
the 3-level nested software form and Files picker mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- update, files and the 6 /api catalog controllers now live in the engine on
a new WarpEngine::ApiController base (same rescue/mime behavior as host)
- engine routes serve /update, /file/*path, /api/software*, /api/builds*,
/api/image/:id, /api/download at unchanged public paths via the root mount;
host routes keep only TTG endpoints (events, members, wiki, rss, swagger)
- /update secret comes from WarpEngine.config.update_secret and an
unconfigured secret now rejects every request (previously an empty
UPDATE_SECRET env accepted empty secrets)
- apipie-rails is an engine dependency (DSL in engine controllers); dummy app
configures apipie with validation off, mirroring the host
- engine request specs: catalog controller specs moved from host plus new
/update auth contract spec
Verified: engine suite 53 green, host suite 6 green, /api/software and
/api/builds byte-identical to baselines, /update 401/400 behavior intact,
admin and TTG endpoints OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- all catalog services (update/software/highlighted/builds/file/file-manager/
download/image + SoftwareResponseBuilder), the SoftwareUpdater platform
services and their concerns, Blueprinter serializers (incl. TimestampFields)
and the 4 DTOs now live in the engine under WarpEngine::
- constantize dispatch strings use absolute names
(WarpEngine::SoftwareUpdater::<Platform>Service)
- container paths read from WarpEngine.config everywhere (FileService,
DownloadService, FileManagerService, ArchiveExtraction, ReleaseSerializer
path rewriting); FileManagerService base path is now lazy
- engine requires blueprinter itself; gemspec declares blueprinter + rubyzip
- engine test suite: spec/dummy app (mysql warp_engine_test, catalog-only
schema), rails_helper with engine-local factories; catalog model/service
specs and factories moved from the host
- host suite keeps TTG specs and loads catalog factories from the engine
Verified: engine suite 40 green, host suite 14 green, /api/software and
/api/builds byte-identical to baselines, admin OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Software, Release, ReleaseAsset, ExternalLink, PlatformLink, Image,
SoftwareImage, Download now live in the engine under WarpEngine::, on top
of WarpEngine::ApplicationRecord; table names unchanged (empty prefix)
- each model runs an ActiveSupport load hook (:warp_engine_<model>) as a
host extension point
- WarpEngine::Image reads its upload path from WarpEngine.config
- host references fully qualified (services, serializers, admin, specs);
admin registrations renamed with as: so /admin URLs and route helpers are
byte-identical; factories pinned to the namespaced classes
- Member#image now class_name: "WarpEngine::Image"
Verified: full suite green, /api/software and /api/builds byte-identical to
the phase-0 baselines, admin routes unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- libs/ruby/warp_engine: gemspec, WarpEngine::Engine (isolate_namespace with
empty table_name_prefix), WarpEngine.configure surface (container paths,
update_secret, image_owners), empty engine routes
- host Gemfile: path gem; host routes: mount WarpEngine::Engine => "/" as
the last entry so host routes always win
- docker: api build context moved to repo root so libs/ is visible at
bundle-install time; ./libs:/libs runtime mount; root .dockerignore to keep
data/ and node_modules out of the build context
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- FileService/DownloadService: lazy container-path resolution instead of
class-load-time realpath (boot no longer requires /softwares to exist)
- downloadCount/totalDownloads: single grouped query passed as Blueprinter
option instead of per-release association counts (fixes N+1)
- admin Images: Member coupling replaced with ImageUsage owner registry
(precursor of the WarpEngine.config.image_owners hook)
- Image: UPLOAD_PATH constant replaced with call-time upload_path accessor
- proper test environment (config/environments/test.rb, softwares_test DB,
hosts.clear) — suite previously ran against the development DB
- spec fixes: case-insensitive uniqueness matchers (MySQL ai_ci collation),
DB-cascade has_many expectations, PlatformLink::SUPPORTED_PLATFORMS
- JSON baselines of /api/software and /api/builds for post-extraction diffing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>