A registry record is a name and a catalog
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

`config` — added this morning in 2.1.0 — is gone, and `storeRepositoryUrl` with it, along
with the two store repositories they pointed at.

2.1.0 had the registry say how each store behaves. Wrong shape: how a store behaves is
fixed per installed client, and this application is the only thing that can see the
machine it runs on. A copy of that on a server was a second authority over decisions this
side had already made correctly — including which directories the store may delete from —
and two authorities are a way to disagree.

Keeping two stores on one machine apart needs none of it. It is a subfolder, derived here:
the store id is a slug of the catalog host, the home is `<id>-desktop`, the games folder is
`<id>`, and that folder is the only subtree the store will ever delete from. Derived from
the *catalog* on purpose — the catalog is what a store is, so two records naming the same
one are the same store and land in the same place, which makes installing twice idempotent
instead of a way to orphan what is already there.

Existing installations keep their identity: a store home is recognised by its own
`config.json`, so one installed as `ttg` stays `ttg` in `ttg-desktop` with its games where
they are. Only a new install derives its id.

`StoreProvisioningService` no longer re-reads the registry before installing. That existed
to keep the renderer from supplying a config, and with no config in the record there is
nothing left to protect: a name and a catalog have no paths in them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 07:27:52 +02:00
co-authored by Claude Opus 5
parent 045c7bf5b7
commit 82590d3ec4
13 changed files with 160 additions and 327 deletions
@@ -7,7 +7,6 @@ export class RegistryStoreDtoMapper {
return {
name: store.name,
catalogUrl: store.catalogUrl,
storeRepositoryUrl: store.storeRepositoryUrl,
storeId: deriveStoreId(store)
}
}
@@ -16,20 +15,8 @@ export class RegistryStoreDtoMapper {
return stores.map((store: RegistryStore): RegistryStoreDto => this.toDto(store))
}
/**
* The window hands a record back when asking for an install — as an identity only.
*
* There is no `config` here on purpose. A store's config decides where files are
* written and which subtree the store may delete from, so it must not be something
* the window can supply; `StoreProvisioningService` reads the real record from the
* registry instead. That is the same rule as `GameDto` carrying no paths.
*/
/** The window hands a record straight back when asking for an install. */
public toModel (dto: RegistryStoreDto): RegistryStore {
return {
name: dto.name,
catalogUrl: dto.catalogUrl,
storeRepositoryUrl: dto.storeRepositoryUrl,
config: null
}
return { name: dto.name, catalogUrl: dto.catalogUrl }
}
}
@@ -30,45 +30,21 @@ export class StoreProvisioningService {
return this.registry.listStores()
}
/**
* Install the chosen store.
*
* The window's choice is taken at face value, which is safe because a record is only a
* name and a catalog: there is no path in it and nothing that decides what may be
* deleted. The store's own configuration is written by the installer from the engine's
* defaults, so the renderer cannot influence where anything lands.
*/
public async installStore (
chosen: RegistryStore,
store: RegistryStore,
progress?: EngineProgressListener
): Promise<InstalledStore> {
const store = await this.resolveFromRegistry(chosen, progress)
const home = this.stores.resolveDefaultHome(deriveStoreId(store))
const installed = await this.installer.installEngine(home, store, progress)
return this.selection.adoptStore(installed)
}
/**
* The registry's own record for the store that was chosen.
*
* The window is handed stores to display and hands one back to install, but what it
* hands back is not what gets used. A store's config decides where files are written
* and which subtree the store may later delete from, so it cannot be something the
* window supplies — the choice is treated as an identity, a name and a catalog, and
* the record behind it is read again here.
*
* A store that has since left the registry, or a registry that cannot be reached, is
* not a reason to refuse the install: it proceeds on the engine's defaults, which is
* what a store with no config gets anyway.
*/
private async resolveFromRegistry (
chosen: RegistryStore,
progress?: EngineProgressListener
): Promise<RegistryStore> {
try {
const listed = await this.registry.listStores()
const found = listed.find((store: RegistryStore): boolean =>
store.catalogUrl === chosen.catalogUrl && store.name === chosen.name)
if (found !== undefined) return found
progress?.onLog?.(
`${chosen.name} is no longer in the registry — installing on the engine's defaults`)
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error)
progress?.onLog?.(
`the registry could not be read again (${reason}) — installing on the engine's defaults`)
}
return { ...chosen, config: null }
}
}
+7 -16
View File
@@ -1,24 +1,15 @@
/**
* A store the site's registry offers.
* A store the site's registry offers: a name and a catalog.
*
* A name and a catalog are what make a store; the other two fields are optional.
* That is the whole record, and it is enough. How a store behaves is not the registry's
* business — this client carries its own store engine, whose defaults cover the
* host-to-asset mapping, the install modes, the platforms and the behaviour — so what
* was actually missing from those defaults is identity, and identity is all this is.
*
* `config` is how that store behaves — which platforms it offers, which release
* statuses it shows, where its games land — in the same shape a store's `config.json`
* had, because it is the same thing moved into the registry. With none, the engine's
* defaults cover all of it and this record covers the identity, which is why a store
* needs nothing of its own to be installable.
*
* `storeRepositoryUrl` is where the store's own repository is, when it has one. It is
* still read as a config source for a registry that has not moved its stores over yet.
* Keeping two stores on one machine out of each other's files is a subfolder, derived
* here from the store's own slug rather than told to us by a server.
*/
export interface RegistryStore {
readonly name: string
readonly catalogUrl: string
readonly storeRepositoryUrl: string | null
/**
* Deliberately not `JsonRecord`: `domain` imports nothing from `infrastructure`, and
* a JSON object is describable without it.
*/
readonly config: Readonly<Record<string, unknown>> | null
}
+10 -16
View File
@@ -3,28 +3,22 @@ import type { RegistryStore } from './RegistryStore'
/**
* A store id, from whatever the registry gave us.
*
* The id names the store home, the folder games land in and the launcher files, so
* it has to be short and filesystem-safe. Three sources, in order of how much they
* were meant to be a name:
* The id names the store home, the folder games land in and the launcher files, so it
* has to be short and filesystem-safe. Two sources, in order of how much they were
* meant to be a name:
*
* 1. the repository name — `ttg-desktop-store` becomes `ttg`;
* 2. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 3. the display name, slugged, as a last resort.
* 1. the catalog host — `https://teletypegames.org` becomes `teletypegames`;
* 2. the display name, slugged, as a last resort.
*
* The store's own config.json overrides all of it whenever one exists.
* Derived rather than carried, and derived from the catalog: the catalog is what a store
* *is*, so two records naming the same catalog are the same store and land in the same
* place, which is what keeps a reinstall from orphaning what is already there.
*/
export function deriveStoreId (store: RegistryStore): string {
const fromRepository = store.storeRepositoryUrl === null
? ''
: (lastSegment(store.storeRepositoryUrl).replace(/-(desktop-)?store$/, ''))
return toSlug(fromRepository) || toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
return toSlug(readHostLabel(store.catalogUrl)) || toSlug(store.name) || 'store'
}
function lastSegment (url: string): string {
return url.replace(/\/+$/, '').split('/').pop() ?? ''
}
/** `https://www.teletypegames.org/x` → `teletypegames`. */
/** `https://www.teletypegames.org/x` -> `teletypegames`. */
function readHostLabel (catalogUrl: string): string {
try {
const host = new URL(catalogUrl).hostname.replace(/^www\./, '')
@@ -1,7 +1,7 @@
import { RegistryUnavailableError } from '../../domain/errors/RegistryUnavailableError'
import type { RegistryStore } from '../../domain/models/RegistryStore'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import { asRecord, readRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { asRecord, readString, type JsonRecord } from '../json/JsonRecord'
import { BuildConfiguration } from '../config/BuildConfiguration'
import type { HttpTextClient } from '../http/HttpTextClient'
@@ -15,10 +15,10 @@ const DEFAULT_REGISTRY_URL = 'https://teletypegames.org/api/stores'
* field a build was packaged with (for shipping a client for another site), and finally
* the address of ours.
*
* A record needs a name and a catalog URL; those two make a store. The config and the
* repository are both optional and arrive as null when absent — a store configured by
* nothing but this record installs on the engine's defaults. Records missing either of
* the two required fields are dropped rather than half-used.
* A name and a catalog URL make a store, and are all a record carries. Anything else it
* happens to say is ignored: how a store behaves is this client's own business, decided
* by the engine it ships with. Records missing either field are dropped rather than
* half-used.
*/
export class HttpStoreRegistryRepository implements StoreRegistryRepository {
public readonly sourceUrl: string
@@ -44,19 +44,12 @@ export class HttpStoreRegistryRepository implements StoreRegistryRepository {
return parsed
.map((row: unknown): JsonRecord | null => asRecord(row))
.filter((row: JsonRecord | null): row is JsonRecord => row !== null)
.map((row: JsonRecord): RegistryStore => {
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
const repository = (
readString(row, 'storeRepositoryUrl') || readString(row, 'store_repository_url')
).trim()
return {
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim(),
storeRepositoryUrl: repository.length > 0 ? repository : null,
config: readRecord(row, 'config')
}
})
// Both spellings, because a registry is someone else's API: ours answers
// camelCase, and a hand-rolled one may not.
.map((row: JsonRecord): RegistryStore => ({
name: readString(row, 'name').trim(),
catalogUrl: (readString(row, 'catalogUrl') || readString(row, 'catalog_url')).trim()
}))
.filter((store: RegistryStore): boolean =>
store.name.length > 0 && store.catalogUrl.length > 0)
}
@@ -6,12 +6,8 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../../domain/models/StoreEngine'
import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
import { asRecord, readString } from '../json/JsonRecord'
import { HttpStatusError, type HttpTextClient } from '../http/HttpTextClient'
const CONFIG_FILE_NAME = 'config.json'
const DEFAULT_FORGE_BASE = 'https://git.teletypegames.org'
const DEFAULT_BRANCH = 'master'
/** What an install used to leave in a store home, back when the engine was a script. */
const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.py']
@@ -19,51 +15,63 @@ const RETIRED_ENGINE_FILES: readonly string[] = ['desktop_store.py', 'warpstore.
/**
* Setting up a store where there is none.
*
* Since the engine moved into this application there is nothing to download but the
* store's own configuration, so an install is one HTTP call and one file. The store
* home stays where it was and keeps its name, because the state and the catalog cache
* beside that config are what make an existing library recognisable.
* Nothing is downloaded and nothing is asked of a server. The engine ships in this
* application and its defaults already cover the host-to-asset mapping, the install
* modes, the platforms and the behaviour; what a registry record adds is identity — a
* name, a catalog and a slug — and that is what gets written.
*
* The config is written to disk rather than kept in memory because it is the store's
* own record of itself: `StoreConfigurationReader` reads it on every operation, an
* existing store home is recognised by it, and a person can look at it.
*/
export class NativeStoreEngineInstaller implements StoreEngineInstaller {
private readonly forgeBase: string
public constructor (private readonly httpClient: HttpTextClient, forgeBase?: string) {
const configured = process.env['FORGE_BASE']
this.forgeBase = forgeBase ?? (configured !== undefined && configured.length > 0
? configured
: DEFAULT_FORGE_BASE)
}
public async installEngine (
public installEngine (
home: string,
store: RegistryStore,
progress: EngineProgressListener = {}
): Promise<InstalledStore> {
fs.mkdirSync(home, { recursive: true })
const config = await this.readStoreConfig(store, progress)
const storeId = deriveStoreId(store)
const configPath = path.join(home, CONFIG_FILE_NAME)
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
fs.writeFileSync(configPath, `${JSON.stringify(this.buildConfig(store, storeId), null, 2)}\n`)
this.removeRetiredEngine(home, progress)
progress.onLog?.(`${store.name} is set up in ${home}`)
const configStore = asRecord(config['store'])
return {
id: configStore === null ? deriveStoreId(store) : readString(configStore, 'id', deriveStoreId(store)),
return Promise.resolve({
id: storeId,
name: store.name,
home,
configPath,
engine: DESKTOP_STORE_ENGINE.id
})
}
/**
* The store's configuration: its identity, and the two things worth stating.
*
* Everything absent from this falls to the engine's defaults, which is most of it. The
* subfolder is named after the store so two stores on one machine cannot reach into
* each other's files — it is the prune boundary, so it has to be the store's own.
* Demo titles are listed because a catalog that publishes them means them to be
* played; the engine defaults to released and archived only, which is the safer
* default for a store nobody configured.
*/
private buildConfig (store: RegistryStore, storeId: string): Record<string, unknown> {
return {
store: { id: storeId, name: store.name, base_url: store.catalogUrl },
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
/**
* Clear out the scripts an older client downloaded here.
*
* A store home provisioned by 1.5.0 or by the shell installer holds two Python
* files that nothing reads any more. They are harmless, but a directory that still
* looks like it holds the engine invites someone to run it against a state file
* this application is also writing.
* A store home provisioned by 1.5.0 or by a shell installer holds two Python files
* that nothing reads any more. They are harmless, but a directory that still looks
* like it holds the engine invites someone to run it against a state file this
* application is also writing.
*/
private removeRetiredEngine (home: string, progress: EngineProgressListener): void {
for (const fileName of RETIRED_ENGINE_FILES) {
@@ -73,88 +81,4 @@ export class NativeStoreEngineInstaller implements StoreEngineInstaller {
progress.onLog?.(`removed the retired ${fileName}`)
}
}
/**
* The store's configuration.
*
* Four cases, and all of them install:
*
* - **a config on the registry record** — the authority on how the store behaves:
* which platforms it offers, which statuses it shows, where things land. No
* request at all, because it arrived with the store list;
* - **a repository with a config.json** — the same thing in its older home, read
* for a registry whose stores have not moved over yet;
* - **a repository without one** (404) — the engine's defaults, as below;
* - **neither** — the same defaults, without the round trip.
*
* The engine's built-in defaults already cover the host-to-asset mapping, the
* modes, the platforms and the behaviour, so what a store actually has to supply is
* identity: a slug, a name and a catalog. That is exactly what a registry record
* carries, which is why a store needs no repository of its own. The registry always
* wins on those three, whatever a config file says.
*/
private async readStoreConfig (
store: RegistryStore,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
const storeId = deriveStoreId(store)
const config = await this.readPublishedConfig(store, storeId, progress)
const existing = asRecord(config['store']) ?? {}
config['store'] = {
...existing,
id: readString(existing, 'id', storeId),
name: store.name,
base_url: store.catalogUrl
}
return config
}
private async readPublishedConfig (
store: RegistryStore,
storeId: string,
progress: EngineProgressListener
): Promise<Record<string, unknown>> {
// The registry's own answer wins, and needs no request: a store's configuration is
// part of its record now rather than a file in a repository that has to exist and
// stay reachable.
if (store.config !== null) {
progress.onLog?.(`${store.name} is configured by the registry`)
return { ...store.config }
}
const repositoryUrl = store.storeRepositoryUrl
if (repositoryUrl === null) {
progress.onLog?.(`${store.name} has no store repository — using the engine defaults`)
return this.defaultConfig(storeId)
}
try {
progress.onLog?.(`reading the store config from ${repositoryUrl}`)
const body = await this.httpClient.readText(this.configUrl(repositoryUrl))
return { ...(asRecord(JSON.parse(body)) ?? {}) }
} catch (error: unknown) {
if (!(error instanceof HttpStatusError) || error.statusCode !== 404) throw error
progress.onLog?.('no config.json in the store repository — using the engine defaults')
return this.defaultConfig(storeId)
}
}
/**
* What a store gets when nothing else says otherwise.
*
* Two fields, on top of the identity added by the caller. The subfolder keeps two
* stores on one machine out of each other's files, and it is the prune boundary, so
* it must be the store's own. Demo titles are listed because a catalog that
* publishes them means them to be played — the engine defaults to released and
* archived only, which is the safer default for a store nobody configured.
*/
private defaultConfig (storeId: string): Record<string, unknown> {
return {
paths: { subfolder: storeId },
catalog: { statuses: ['released', 'archived', 'demo'] }
}
}
private configUrl (repositoryUrl: string, branch: string = DEFAULT_BRANCH): string {
return `${repositoryUrl.replace(/\/+$/, '')}/raw/branch/${branch}/${CONFIG_FILE_NAME}`
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export class ServiceContainer {
const stores = new FileSystemInstalledStoreRepository()
const catalogGateway = new NativeStoreCatalogGateway()
const registry = new HttpStoreRegistryRepository(httpClient)
const installer = new NativeStoreEngineInstaller(httpClient)
const installer = new NativeStoreEngineInstaller()
const preferencesRepository = new JsonFilePreferencesRepository(environment)
const preferences = new PreferencesService(preferencesRepository, environment)
-3
View File
@@ -27,12 +27,9 @@ export function requireStringArray (value: unknown, name: string): readonly stri
export function requireRegistryStore (value: unknown): RegistryStoreDto {
const record = asRecord(value)
if (record === null) throw new TypeError('a store record is required')
const repository = readString(record, 'storeRepositoryUrl')
const store: RegistryStoreDto = {
name: readString(record, 'name'),
catalogUrl: readString(record, 'catalogUrl'),
// Optional: a store with no repository installs on the engine's defaults.
storeRepositoryUrl: repository.length > 0 ? repository : null,
storeId: readString(record, 'storeId')
}
if (store.name.length === 0 || store.catalogUrl.length === 0) {
+3 -40
View File
@@ -3,7 +3,6 @@ import path from 'node:path'
import { GameDtoMapper } from '../application/mappers/GameDtoMapper'
import type { CatalogListing } from '../domain/models/CatalogListing'
import type { InstalledStore } from '../domain/models/InstalledStore'
import type { RegistryStore } from '../domain/models/RegistryStore'
import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine'
import { deriveStoreId } from '../domain/models/StoreIdentity'
import { NativeStoreCatalogGateway } from '../infrastructure/engine/NativeStoreCatalogGateway'
@@ -66,53 +65,17 @@ class SmokeTest {
return
}
this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`)
// The slug is worth printing: it names the store home and the games subfolder, and
// it is derived here rather than told to us, so a wrong catalog URL shows up as a
// wrong folder name before anything is installed.
for (const store of stores) {
this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`)
await this.checkStoreConfig(store)
}
} catch (error: unknown) {
this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`)
}
}
/**
* Where this store's configuration would come from, in the order the installer asks.
*
* A store needs no config and no repository: either way the engine's defaults carry
* it, so every absence here is reported rather than failed. What is worth seeing is
* *which* source answered — a store still being configured by a repository is a store
* that has not moved over to the registry yet.
*/
private async checkStoreConfig (store: RegistryStore): Promise<void> {
if (store.config !== null) {
const subfolder = this.readSubfolder(store.config)
this.reportOk(' config', `${String(Object.keys(store.config).length)} sections ` +
`from the registry${subfolder === null ? '' : `, subfolder ${subfolder}`}`)
return
}
if (store.storeRepositoryUrl === null) {
this.reportOk(' config', 'none, and no repository — the engine defaults would be used')
return
}
const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json`
try {
const config: unknown = JSON.parse(await this.httpClient.readText(url))
const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0
this.reportOk(' config.json', `${String(sections)} sections from the repository ` +
'(not yet moved to the registry)')
} catch (error: unknown) {
this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`)
}
}
/** The prune boundary, which is the field worth seeing at a glance. */
private readSubfolder (config: Readonly<Record<string, unknown>>): string | null {
const paths = config['paths']
if (typeof paths !== 'object' || paths === null) return null
const subfolder = (paths as Readonly<Record<string, unknown>>)['subfolder']
return typeof subfolder === 'string' ? subfolder : null
}
private findStore (): InstalledStore | null {
const sandbox = process.env['SMOKE_HOME']
if (sandbox !== undefined && sandbox.length > 0) {
+1 -3
View File
@@ -2,8 +2,6 @@
export interface RegistryStoreDto {
readonly name: string
readonly catalogUrl: string
/** Null when the store has no repository of its own; the engine's defaults are then used. */
readonly storeRepositoryUrl: string | null
/** Derived from the repository, the catalog host or the name — what the store will be called on disk. */
/** Derived from the catalog host, or the name — what the store will be called on disk. */
readonly storeId: string
}