From 021f2b9b07262e51436867d512a303a897093f92 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 19 Aug 2026 07:19:52 +0200 Subject: [PATCH] A store record is a name and a catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/app/admin/stores.rb | 38 ++--------- .../app/controllers/api/stores_controller.rb | 20 ++---- apps/api/app/models/store.rb | 68 +++---------------- apps/api/app/serializers/store_serializer.rb | 7 -- ...9120000_a_store_is_a_name_and_a_catalog.rb | 18 +++++ apps/api/db/schema.rb | 4 +- apps/api/db/seeds.rb | 6 +- .../controllers/stores_controller_spec.rb | 32 +-------- apps/api/spec/factories/stores.rb | 1 - apps/api/spec/models/store_spec.rb | 57 ---------------- apps/frontend/src/i18n/locales/en.ts | 5 -- apps/frontend/src/i18n/locales/hu.ts | 5 -- .../src/page/stores/StoresIndexPage.vue | 31 ++------- apps/frontend/src/router/stores.router.ts | 4 +- 14 files changed, 51 insertions(+), 245 deletions(-) create mode 100644 apps/api/db/migrate/20260819120000_a_store_is_a_name_and_a_catalog.rb diff --git a/apps/api/app/admin/stores.rb b/apps/api/app/admin/stores.rb index a931e1a..19e7b72 100644 --- a/apps/api/app/admin/stores.rb +++ b/apps/api/app/admin/stores.rb @@ -1,5 +1,5 @@ ActiveAdmin.register Store do - permit_params :name, :catalog_url, :store_repository_url, :config_json + permit_params :name, :catalog_url menu priority: 5, label: "πŸ›’ Stores" @@ -10,19 +10,12 @@ ActiveAdmin.register Store do column :catalog_url do |store| link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener" end - column :store_repository_url do |store| - link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener" - end - column :config do |store| - store.config.blank? ? status_tag("engine defaults") : status_tag("configured", class: "ok") - end column :updated_at actions end filter :name filter :catalog_url - filter :store_repository_url show do attributes_table do @@ -31,25 +24,14 @@ ActiveAdmin.register Store do row :catalog_url do |store| link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener" end - row :store_repository_url do |store| - link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener" - end - row :config do |store| - if store.config.blank? - "β€” the client uses the store engine's defaults" - else - pre JSON.pretty_generate(store.config) - end - end row :created_at row :updated_at end para do - "Listed by GET /api/stores, which the desktop client reads on first run. The " \ - "config above is what the client applies, pointed at the catalog above; leave " \ - "it empty and the client uses the store engine's defaults, which need nothing " \ - "but the name and the catalog. The client always takes the name, the catalog " \ - "and the store's slug from this record, whatever the config says." + "Listed by GET /api/stores, which the graphical client reads on first run. A name " \ + "and a catalog are the whole record: the client carries its own store engine " \ + "and configures itself from this much, deriving the store's slug from the " \ + "catalog host." end end @@ -57,16 +39,6 @@ ActiveAdmin.register Store do f.inputs do f.input :name, hint: "What the client shows in its store picker" f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org" - f.input :store_repository_url, - hint: "Optional, and only a pointer for a person now: where this store's " \ - "own repository is, if it has one. Clients from before the config " \ - "field below still fetch a config.json from it" - f.input :config_json, as: :text, input_html: { rows: 20, style: "font-family: monospace" }, - label: "Config (JSON)", - hint: "Optional. The store's own configuration β€” platforms, release " \ - "statuses, where games land β€” in the shape a store's config.json " \ - "had. Leave it empty and the client uses the store engine's " \ - "defaults. Invalid JSON is refused with the text kept" end f.actions end diff --git a/apps/api/app/controllers/api/stores_controller.rb b/apps/api/app/controllers/api/stores_controller.rb index 75eb051..e1ff6a3 100644 --- a/apps/api/app/controllers/api/stores_controller.rb +++ b/apps/api/app/controllers/api/stores_controller.rb @@ -6,26 +6,16 @@ class Api::StoresController < ApiController api :GET, "/api/stores", "List the stores a client can install from" desc <<~DESC The registry the graphical desktop client reads on first run: which catalogs - exist, and how each store behaves. Public on purpose: a client has nobody to log - in as. + exist. Public on purpose: a client has nobody to log in as. - A record needs a name and a catalog URL. Both other fields are optional and answer - `null` when unset. `config` is the store's own configuration, in the shape a - store's `config.json` had β€” a client applies it and takes the name, the catalog and - the store's slug from this record regardless. With no `config` a client uses the - store engine's built-in defaults, so neither a config nor a repository has to exist - for a store to be installable. - - `storeRepositoryUrl` is a pointer to the store's own repository where it has one. - Clients released before `config` existed fetch a `config.json` from it instead. + A name and a catalog URL are the whole record. How a store behaves is fixed per + installed client β€” it carries its own store engine and knows its own machine β€” so + the registry says what a store *is* and nothing about how it works. DESC returns code: 200, desc: "Array of stores" do property :name, String, desc: "Display name of the store" property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves" - property :storeRepositoryUrl, [String, nil], - desc: "The store's own repository, or null when it has none" - property :config, [Hash, nil], - desc: "The store's configuration, or null when it uses the engine's defaults" + end def index render json: StoreService.new.index diff --git a/apps/api/app/models/store.rb b/apps/api/app/models/store.rb index 1f530ef..ee067e3 100644 --- a/apps/api/app/models/store.rb +++ b/apps/api/app/models/store.rb @@ -1,73 +1,25 @@ -# A store a client can install from: a WarpEngine catalog, and β€” optionally β€” the -# configuration that says how that store behaves. +# A store a client can install from: a name and a WarpEngine catalog. # -# This is deliberately not part of WarpEngine. The engine serves one catalog and -# has no business knowing which stores exist for it; the registry is a property of -# this site, which is what the graphical client asks. +# That is the whole record, and deliberately so. A client takes identity from it β€” the +# name, the catalog, and a slug derived from the catalog host β€” and everything else from +# the store engine it carries. How a store behaves is fixed per installed client, which +# knows its own machine; a copy of it here would be a second authority over decisions +# the client has already made, including which directories it may delete from. # -# Both extras are optional, because a store needs neither. A client takes identity -# from this record β€” the name, the catalog and a slug derived from it β€” and everything -# else from the store engine's built-in defaults. -# -# `config` is that store's own configuration: which platforms it offers, which release -# statuses it shows, where its games land. It used to be a `config.json` in a -# repository the client fetched; carrying it here makes this record the one source of -# truth and removes a second thing that had to exist and stay reachable. -# -# `store_repository_url` is now only a pointer for a person β€” where the store's own -# repository is, when it has one. A client that predates `config` still reads a -# `config.json` from it, which is why it stays. +# This is deliberately not part of WarpEngine. The engine serves one catalog and has no +# business knowing which stores exist for it; the registry is a property of this site, +# which is what the graphical client asks. class Store < ApplicationRecord URL = %r{\Ahttps?://\S+\z} validates :name, presence: true validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" } - validates :store_repository_url, format: { with: URL, message: "must be an http(s) URL" }, - allow_blank: true - validate :config_must_be_an_object - validate :config_json_must_parse default_scope { where(deleted_at: nil) } scope :ordered, -> { order(:name) } def self.ransackable_attributes(auth_object = nil) - %w[id name catalog_url store_repository_url created_at updated_at] - end - - # The config as JSON text, which is the only form an admin form can edit. The column - # still holds real JSON β€” this is a view of it, not a second copy. - def config_json - return @config_json if defined?(@config_json) && !@config_json.nil? - - config.blank? ? "" : JSON.pretty_generate(config) - end - - def config_json=(text) - @config_json = text - @config_json_error = nil - if text.blank? - self.config = nil - return - end - self.config = JSON.parse(text) - rescue JSON::ParserError => e - # Kept rather than raised: the form has to come back with the text the person - # typed and a message, not a 500. - @config_json_error = e.message - end - - private - - def config_must_be_an_object - return if config.nil? || config.is_a?(Hash) - - errors.add(:config, "must be a JSON object") - end - - def config_json_must_parse - return if @config_json_error.blank? - - errors.add(:config, "is not valid JSON: #{@config_json_error}") + %w[id name catalog_url created_at updated_at] end end diff --git a/apps/api/app/serializers/store_serializer.rb b/apps/api/app/serializers/store_serializer.rb index 5933f69..e09bb0d 100644 --- a/apps/api/app/serializers/store_serializer.rb +++ b/apps/api/app/serializers/store_serializer.rb @@ -5,11 +5,4 @@ class StoreSerializer < Blueprinter::Base # camelCase, as the catalog's own payloads use β€” one convention for a client # that reads both. field(:catalogUrl) { |store| store.catalog_url } - # Null rather than an empty string when there is no repository: the client - # branches on its absence, and "" is not an absence a JSON reader can trust. - field(:storeRepositoryUrl) { |store| store.store_repository_url.presence } - # The store's own configuration, in the shape a store's `config.json` had β€” the - # client applies it exactly as it applied that file. Null when the store configures - # nothing, and then the client uses the engine's defaults. - field(:config) { |store| store.config.presence } end diff --git a/apps/api/db/migrate/20260819120000_a_store_is_a_name_and_a_catalog.rb b/apps/api/db/migrate/20260819120000_a_store_is_a_name_and_a_catalog.rb new file mode 100644 index 0000000..bdf9e08 --- /dev/null +++ b/apps/api/db/migrate/20260819120000_a_store_is_a_name_and_a_catalog.rb @@ -0,0 +1,18 @@ +class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1] + # Both extras go. A store record is a name and a catalog, and nothing else. + # + # `config` was added earlier today on the idea that the registry should say how each + # store behaves. It should not: the configuration is fixed per installed client β€” the + # client carries it and knows its own machine β€” so a copy on the server was a second + # authority over decisions the client had already made correctly, including where it + # may delete. Keeping two stores on one machine apart is a subfolder, which the client + # derives itself. + # + # `store_repository_url` goes for the same reason it stopped being read: the store + # engines it pointed at do not exist any more, and a URL nobody follows is a URL that + # goes stale. + def change + remove_column :stores, :config, :json + remove_column :stores, :store_repository_url, :string + end +end diff --git a/apps/api/db/schema.rb b/apps/api/db/schema.rb index 2c04507..a4fe8fa 100644 --- a/apps/api/db/schema.rb +++ b/apps/api/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_19_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_19_120000) do create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "deleted_at", precision: 3 @@ -283,11 +283,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_19_000001) do create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "catalog_url", null: false - t.json "config" t.datetime "created_at", precision: 3, null: false t.datetime "deleted_at", precision: 3 t.string "name", null: false - t.string "store_repository_url" t.datetime "updated_at", precision: 3, null: false t.index ["deleted_at"], name: "idx_stores_deleted_at" t.index ["name"], name: "idx_stores_name" diff --git a/apps/api/db/seeds.rb b/apps/api/db/seeds.rb index c2df4ef..7f1e9c4 100644 --- a/apps/api/db/seeds.rb +++ b/apps/api/db/seeds.rb @@ -20,9 +20,5 @@ end # first record; anyone running this site would add their own the same way, from # the admin panel or here. Store.find_or_create_by!(name: "Teletype Games") do |store| - store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org") - store.store_repository_url = ENV.fetch( - "STORE_REPOSITORY_URL", - "https://git.teletypegames.org/stores/ttg-desktop-store" - ) + store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org") end diff --git a/apps/api/spec/controllers/stores_controller_spec.rb b/apps/api/spec/controllers/stores_controller_spec.rb index 21a0d7c..8fb0c05 100644 --- a/apps/api/spec/controllers/stores_controller_spec.rb +++ b/apps/api/spec/controllers/stores_controller_spec.rb @@ -19,36 +19,10 @@ RSpec.describe Api::StoresController, type: :request do get "/api/stores" store = JSON.parse(response.body).first - expect(store).to include("name", "catalogUrl", "storeRepositoryUrl", "config") + # Exactly two fields: a name and a catalog are the whole record, and a client + # that starts reading a third would be reading something this site no longer says. + expect(store.keys).to contain_exactly("name", "catalogUrl") expect(store["catalogUrl"]).to eq("https://teletypegames.org") - expect(store["storeRepositoryUrl"]).to end_with("/stores/ttg-desktop-store") - end - - # The config is what the client applies instead of fetching a repository's - # config.json, so it has to come back as an object rather than as JSON text. - it "carries the store's own config as an object" do - create(:store, config: { "paths" => { "subfolder" => "teletypegames" } }) - - get "/api/stores" - - config = JSON.parse(response.body).first["config"] - expect(config).to eq("paths" => { "subfolder" => "teletypegames" }) - end - - it "answers null for a store that configures nothing" do - create(:store, config: nil) - - get "/api/stores" - - expect(JSON.parse(response.body).first["config"]).to be_nil - end - - it "answers null for a store with no repository" do - create(:store, store_repository_url: nil) - - get "/api/stores" - - expect(JSON.parse(response.body).first["storeRepositoryUrl"]).to be_nil end it "leaves out soft-deleted stores" do diff --git a/apps/api/spec/factories/stores.rb b/apps/api/spec/factories/stores.rb index c13046a..02f0acb 100644 --- a/apps/api/spec/factories/stores.rb +++ b/apps/api/spec/factories/stores.rb @@ -2,6 +2,5 @@ FactoryBot.define do factory :store do name { "Teletype Games" } catalog_url { "https://teletypegames.org" } - store_repository_url { "https://git.teletypegames.org/stores/ttg-desktop-store" } end end diff --git a/apps/api/spec/models/store_spec.rb b/apps/api/spec/models/store_spec.rb index 5b28979..c27c1fa 100644 --- a/apps/api/spec/models/store_spec.rb +++ b/apps/api/spec/models/store_spec.rb @@ -10,63 +10,6 @@ RSpec.describe Store, type: :model do expect(store.errors[:catalog_url]).to include("must be an http(s) URL") end - it "rejects a repository url that is not http(s)" do - expect(build(:store, store_repository_url: "not a url")).not_to be_valid - end - - # A store without a repository is a complete store: the client takes identity - # from the record and everything else from the engine's defaults. - it "accepts a store with no repository at all" do - expect(build(:store, store_repository_url: nil)).to be_valid - expect(build(:store, store_repository_url: "")).to be_valid - end - - describe "config" do - it "accepts a store that configures nothing" do - expect(build(:store, config: nil)).to be_valid - end - - it "rejects a config that is not a JSON object" do - store = build(:store, config: ["not", "an", "object"]) - expect(store).not_to be_valid - expect(store.errors[:config]).to include("must be a JSON object") - end - - # The admin form edits JSON text; the column holds real JSON. These two accessors - # are the only bridge between them, so both directions are worth pinning. - it "parses config_json into the column" do - store = build(:store, config_json: '{"paths": {"subfolder": "teletypegames"}}') - - expect(store).to be_valid - expect(store.config).to eq("paths" => { "subfolder" => "teletypegames" }) - end - - it "renders the column back as pretty JSON text" do - store = build(:store, config: { "paths" => { "subfolder" => "teletypegames" } }) - - expect(JSON.parse(store.config_json)).to eq(store.config) - expect(store.config_json).to include("\n") - end - - it "clears the config when the text is emptied" do - store = build(:store, config: { "paths" => {} }) - store.config_json = "" - - expect(store).to be_valid - expect(store.config).to be_nil - end - - # Refused with the text kept, rather than raising: the form has to come back with - # what the person typed and a message they can act on. - it "refuses invalid JSON and keeps the text" do - store = build(:store, config_json: '{"paths": ') - - expect(store).not_to be_valid - expect(store.errors[:config].first).to start_with("is not valid JSON") - expect(store.config_json).to eq('{"paths": ') - end - end - describe ".ordered" do it "lists stores by name" do later = create(:store, name: "Zed Games") diff --git a/apps/frontend/src/i18n/locales/en.ts b/apps/frontend/src/i18n/locales/en.ts index 134eeb6..618d22f 100644 --- a/apps/frontend/src/i18n/locales/en.ts +++ b/apps/frontend/src/i18n/locales/en.ts @@ -218,11 +218,6 @@ export default { platformsTitle: 'What gets installed', copy: 'Copy', - desktop: { - name: 'Desktop CLI', - site: 'Teletype Games', - tagline: 'For scripts and servers', - }, batocera: { name: 'Batocera', site: 'Batocera project', diff --git a/apps/frontend/src/i18n/locales/hu.ts b/apps/frontend/src/i18n/locales/hu.ts index c9dadc1..97d5ecc 100644 --- a/apps/frontend/src/i18n/locales/hu.ts +++ b/apps/frontend/src/i18n/locales/hu.ts @@ -218,11 +218,6 @@ export default { platformsTitle: 'Mi kerΓΌl fel', copy: 'MΓ‘solΓ‘s', - desktop: { - name: 'Desktop CLI', - site: 'Teletype Games', - tagline: 'Szkriptekhez Γ©s szerverekhez', - }, batocera: { name: 'Batocera', site: 'Batocera projekt', diff --git a/apps/frontend/src/page/stores/StoresIndexPage.vue b/apps/frontend/src/page/stores/StoresIndexPage.vue index 5fb14df..56475bf 100644 --- a/apps/frontend/src/page/stores/StoresIndexPage.vue +++ b/apps/frontend/src/page/stores/StoresIndexPage.vue @@ -137,7 +137,6 @@ const route = useRoute() const router = useRouter() const FORGE = 'https://git.teletypegames.org/stores' -const ENGINES_FORGE = 'https://git.teletypegames.org/engines' // The graphical client. Its own thing rather than a link on the desktop store: it // drives any WarpEngine store the site's registry offers, and on Windows it is the @@ -148,11 +147,13 @@ const CLIENT = { wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`, } -type DeviceId = 'desktop' | 'batocera' | 'retroarch' +type DeviceId = 'batocera' | 'retroarch' +// The devices below are the ones a store reaches through a shell installer. An +// ordinary computer is not among them any more: there the store *is* the app in the +// section above, which carries its own engine and needs nothing installed first. const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store' const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store' -const DESKTOP_CLI = '~/.local/bin/ttg-desktop-store' const devices = [ { @@ -172,22 +173,6 @@ const devices = [ `${BATOCERA_CLI} remove c64demo`, ].join('\n'), }, - { - id: 'desktop' as DeviceId, - icon: 'fa-solid fa-desktop', - projectUrl: 'https://teletypegames.org', - repoUrl: `${FORGE}/ttg-desktop-store`, - wikiUrl: `${CONFIG.wikiBase}/stores/ttg-desktop-store`, - installCmd: `curl -fsSL ${FORGE}/ttg-desktop-store/raw/branch/master/install.sh | sh`, - afterInstallCmd: '', - uninstallCmd: `curl -fsSL ${FORGE}/ttg-desktop-store/raw/branch/master/uninstall.sh | sh`, - cliSnippet: [ - `${DESKTOP_CLI} paths # where things go on this machine`, - `${DESKTOP_CLI} list # what is installable here, and how`, - `${DESKTOP_CLI} sync # install everything new`, - `${DESKTOP_CLI} remove pong`, - ].join('\n'), - }, { id: 'retroarch' as DeviceId, icon: 'fa-solid fa-gamepad', @@ -213,7 +198,7 @@ type ViewId = 'client' | 'others' // query-only change, so switching does not throw the page back to the top. const initial = devices.some((d) => d.id === route.query.device) ? (route.query.device as DeviceId) - : 'desktop' + : 'batocera' const device = ref(initial) // The client view is what most visitors want, so it is the default β€” except when the URL // names a device, which is how /batocera and /retroarch redirect here. Somebody arriving @@ -242,12 +227,6 @@ const platforms = [ { platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' }, { platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' }, ] - -const engines = [ - { name: 'warpstore', url: `${ENGINES_FORGE}/warpstore` }, - { name: 'warp-engine-batocera-store', url: `${FORGE}/warp-engine-batocera-store` }, - { name: 'warp-engine-retroarch-store', url: `${FORGE}/warp-engine-retroarch-store` }, -]