A store record is a name and a catalog
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<DeviceId>(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` },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,5 +7,7 @@ export const storesRouter: RouteRecordRaw[] = [
|
||||
// makes after reading "RetroArch store" — and each lands on its own tab.
|
||||
{ path: '/batocera', redirect: { name: 'storesIndex', query: { device: 'batocera' } } },
|
||||
{ path: '/retroarch', redirect: { name: 'storesIndex', query: { device: 'retroarch' } } },
|
||||
{ path: '/desktop', redirect: { name: 'storesIndex', query: { device: 'desktop' } } },
|
||||
// `/desktop` used to name a shell store for ordinary computers. That store is the
|
||||
// app now, so the old URL lands on the app rather than on a device tab.
|
||||
{ path: '/desktop', redirect: { name: 'storesIndex' } },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user