Stores can be removed, and added from an address you type
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful

Two gaps that were the same gap: the store list could only ever grow, and it could
only grow from what the registry happened to offer.

**Removing** uninstalls what the store installed, then deletes the store itself,
in that order. The order is the whole of it: `state.json` is the only record of
which payloads, icons and menu entries belong to a store, so deleting the home
first would strip the one thing that knows — leaving files nothing could ever
identify, least of all a later install of the same store into the same folder.
The confirmation says how many titles will go, because that is the part nobody
would otherwise expect. The token goes too; a credential for a store that is not
here is a secret kept for nothing.

The window names a *store*, never a path: the home is resolved against what a
disk scan actually found before anything is deleted, and `removeHome` refuses
anything else. That is the only guard between a bad argument and `rm -rf`, so it
has a test.

**Adding** moved to a + beside Refresh — both are actions on the whole store
rather than on one of them, and the full-width button under the list read as a
third store — and the picker now takes a catalog address as well as a listed one.
A bare host is enough and the name comes from the address; nothing else about
installing changes, which is why the typed path hands the same record to the same
method instead of growing a second one. The picker also has a Cancel now: opening
it with a store installed used to replace the grid with no way back.

`make storetest` is new, and it earned itself immediately. Removal is the only
code here that deletes a directory tree, which the smoke test cannot cover — it
runs against the real machine and would have to delete a real store to prove
anything. Two bugs on the first run:

- `http://` was accepted and became a store called *http*. The trailing slashes
  were stripped before the scheme was checked, turning `http://` into `http:` and
  then into `https://http:`, whose hostname parses as "http". The URL is rebuilt
  from the parsed form now, which also settles the trailing slash in one place.
- `STORE_ROOT` only *prepended* to the search path, so a "sandboxed" run still
  listed the real stores — despite the README saying "instead of the real one".
  Harmless while a sandbox could only add; not harmless now that it can delete.
  It replaces the search path.

The self-test needed two changes, both of which are it working: the store row is
a wrapper now, so clicking `.store-row` did nothing at all, and the footer icon
check counted exactly two named controls when there are three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:54:17 +02:00
co-authored by Claude Opus 5
parent 255c588cbd
commit 6285d93790
27 changed files with 823 additions and 72 deletions
@@ -45,6 +45,18 @@ export class PreferencesService {
this.merge({ storeHome: home })
}
/**
* Stop remembering a store, for when it is no longer on the machine.
*
* The key is removed rather than blanked: an empty string would be a remembered home
* that matches nothing, and every reader would have to know to treat it as absent.
*/
public forgetStoreHome (): void {
const { storeHome, ...rest } = this.repository.read()
void storeHome
this.repository.write(rest)
}
private merge (changes: Preferences): void {
this.repository.write({ ...this.repository.read(), ...changes })
}
@@ -4,6 +4,7 @@ import type { RegistryStore } from '../../domain/models/RegistryStore'
import { deriveStoreId } from '../../domain/models/StoreIdentity'
import type { InstalledStoreRepository } from '../../domain/ports/InstalledStoreRepository'
import type { StoreEngineInstaller } from '../../domain/ports/StoreEngineInstaller'
import type { StoreCatalogGateway } from '../../domain/ports/StoreCatalogGateway'
import type { StoreRegistryRepository } from '../../domain/ports/StoreRegistryRepository'
import type { StoreSelectionService } from './StoreSelectionService'
@@ -19,7 +20,8 @@ export class StoreProvisioningService {
private readonly registry: StoreRegistryRepository,
private readonly installer: StoreEngineInstaller,
private readonly stores: InstalledStoreRepository,
private readonly selection: StoreSelectionService
private readonly selection: StoreSelectionService,
private readonly catalogGateway: StoreCatalogGateway
) {}
public get registryUrl (): string {
@@ -47,4 +49,79 @@ export class StoreProvisioningService {
return this.selection.adoptStore(installed)
}
/**
* A catalog the registry does not offer.
*
* Nothing about installing changes — a record is still a name and a catalog, and the
* configuration still comes from the engine's defaults. What differs is only where
* the two fields came from, which is why this hands the same record to the same
* method rather than growing a second path.
*
* The name is derived from the host when none is given: it is a label for the picker,
* and asking somebody to invent one before they can try a URL is a question with no
* useful answer.
*/
public async installCatalog (
catalogUrl: string,
name: string | null = null,
progress?: EngineProgressListener
): Promise<InstalledStore> {
const url = normaliseCatalogUrl(catalogUrl)
const chosen: RegistryStore = { name: name?.trim() ?? '', catalogUrl: url }
return await this.installStore(
chosen.name.length > 0 ? chosen : { ...chosen, name: readHostName(url) },
progress
)
}
/**
* Remove a store: everything it installed, then the store itself.
*
* Whichever store is open afterwards is decided by re-reading the disk rather than
* guessed at here — removing the open one has to leave the window pointing at
* something that exists, and that answer lives in one place.
*/
public async removeStore (store: InstalledStore, progress?: EngineProgressListener): Promise<void> {
await this.catalogGateway.removeStore(store, progress)
this.selection.forgetStore(store)
}
}
/**
* What somebody typed, as a URL this can be used as.
*
* Two liberties taken on purpose, because both are what a person means: a bare host
* gets https, and a trailing slash goes. Anything still unparseable is refused here
* rather than at the first fetch — a store home written for a bad URL is a directory
* somebody has to find and delete.
*/
function normaliseCatalogUrl (value: string): string {
const trimmed = value.trim()
if (trimmed.length === 0) throw new Error('a catalog address is needed')
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
let parsed: URL
try {
parsed = new URL(withScheme)
} catch {
throw new Error(`not a usable address: ${value}`)
}
// A URL can parse and still have no host — `http://` does. That one used to slip
// through and become a store called "http", because the trailing slashes were being
// stripped *before* the scheme was checked, turning `http://` into `http:` and then
// into `https://http:`.
if (parsed.hostname.length === 0) throw new Error(`not a usable address: ${value}`)
// Rebuilt from the parsed URL rather than from the string: it drops the query and
// the fragment — a catalog is a base address, not a request — and settles the
// trailing slash in one place instead of at every call site that appends a path.
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '')
}
function readHostName (catalogUrl: string): string {
try {
return new URL(catalogUrl).hostname.replace(/^www\./, '')
} catch {
return catalogUrl
}
}
@@ -40,6 +40,13 @@ export class StoreSelectionService {
return store
}
/** The store at this home, or an error naming it. Does not change what is open. */
public requireStoreAt (home: string): InstalledStore {
const store = this.stores.findByHome(home)
if (store === null) throw new StoreMissingError(home)
return store
}
public selectStore (home: string): InstalledStore {
const store = this.stores.findByHome(home)
if (store === null) throw new StoreMissingError(home)
@@ -55,6 +62,20 @@ export class StoreSelectionService {
return store
}
/**
* Forget a store that is no longer on the machine.
*
* The next store is not chosen here: `findCurrentStore` re-reads the disk and applies
* the same rule it always does, so "which store is open" has exactly one answer in
* one place. Clearing the remembered home first is what stops it choosing the one
* that has just been deleted.
*/
public forgetStore (store: InstalledStore): void {
if (this.preferences.readStoreHome() === store.home) this.preferences.forgetStoreHome()
if (this.current?.home === store.home) this.current = null
this.findCurrentStore()
}
public readDefaultStoreRoot (): string {
return this.stores.readRoots()[0] ?? ''
}