One plugin = one repo, matching the house pattern (playnite, rom-manager and
virtualhere are already each their own repo with their own biome/bunfig/tsconfig
/CI). The implementation plan's WP5.0 had proposed a single workspace repo for
all six library scanners; this is the piece that makes the split cost nothing.
Everything the six scanners share is already published rather than adjacent: the
parsers and defineLibraryPlugin live in @punktfunk/plugin-kit/library, so repo
boundaries are irrelevant to them. Fixtures are not shared in practice either —
the Rust scanners build theirs inline in code, there are no fixture files, and
the one genuinely cross-plugin builder (binary shortcuts.vdf) is already in this
package's own tests. A pga.db fixture is useless to the epic plugin.
The parity harness was the exception: generic across all six, and parked in the
shared repo the plan assumed. It moves here.
What it is: the acceptance gate for an extracted scanner. Ported unit tests pin
the PARSERS; they do not prove the plugin reproduces the scanner it replaces. A
plugin that parses perfectly and emits steam:440.0 instead of steam:440 breaks
every Moonlight pin on the host and no parser test notices.
punktfunk-plugin-steam parity --snapshot before.json # host on its built-in
punktfunk-plugin-steam parity --compare before.json # offline; exits non-zero
--compare runs the plugin's own scan rather than requiring it to be installed
first, so a mismatch is visible before anything is published and the run is
repeatable while you fix it.
Three judgement calls in the diff, each pinned by a test:
* art is compared by PRESENCE, not value. The representation legitimately
changes on extraction (a host-relative proxy path or inlined data: URL
becomes a file:// path or a CDN URL), so comparing values would fail every
run for no reason. Losing an art kind fails; gaining one does not.
* launcher entries (role: "launcher") are reported separately instead of as
unexpected extras — the built-in scanner had no concept of them, so they can
never be in a baseline. An ORDINARY title the scanner never had still fails,
which is what catches a bad tool filter.
* absent and empty are the same thing in metadata: the host omits empty lists
and nulls, so a plugin sending genres: [] has not changed anything.
plugin-kit: tsc clean, 56 tests pass (10 new).
A plugin's titles launch through the provider's own client, which hands off and
exits — so the host had nothing left to watch, and both lifetime behaviors went
quiet for exactly the entries a provider contributes. A `ProviderEntry` (and a
manual custom entry) may now carry an optional `detect` hint: install dir, exe,
or process name.
It is deliberately a subset of what the host tracks internally. A Steam appid or
a launcher's environment marker are things the host discovers for itself and
would be meaningless — or dangerous — to take on someone's word; where a title
is installed is something only the provider knows. The host's own findings win
where both exist, so a stale export can never redirect the matcher, and a blank
field is treated as absent rather than as "match everything" — an empty install
dir would otherwise prefix-match every process on the box, and this feature can
end processes.
`process_name` is the weakest of the three and the only one typed by hand, so it
is matched case-insensitively against the image's file name and nothing else:
`retroarch` finds RetroArch, not a helper whose name merely starts the same way,
and not a script that happens to live in a `retroarch/` directory. The
never-adopt-a-pre-existing-process rule still bounds it.
Also: the tray summary gains the running-game row (with the closing-in countdown
for a game whose client is gone — visible at the machine without opening the
console), the SDK mirrors the `game.*` events, and its generated client catches
up with the endpoints Phase 1 added.
Gates on .21: check + clippy --all-targets clean, 299 tests, fmt CI-parity,
openapi regenerated (GameEntry still carries no `detect` outbound); SDK tsc +
54 tests green.
The ~80% of every plugin that was copy-pasted (rom-manager ↔ playnite),
extracted as one Effect-v4 package:
- runtime: definePluginKit — async-main boundary hiding a ManagedRuntime
(two-effect-instances discipline), signal-driven interruption with a
bounded shutdown grace
- config: Schema-driven raw round-trip (defaults only in the Schema via
withDecodingDefaultKey + encodingStrategy omit; file stays authored-shape),
atomic writes, world-writable refusal, changes stream
- cache-store: disposable derived state, corrupt→empty, write-through
- reconcile: kit-owned provider wire schemas + typed client over the
skew-safe untyped pf.request seam
- sync-engine: generic poll/watch/debounce/single-flight-coalesce/
fingerprint-skip engine with a status PubSub (the SSE feed)
- ui-server + sse: effect/unstable/httpapi behind servePluginUi with
core-only env layers (validated by the phase-0 spikes); raw SSE route
(httpapi has no event-stream media type)
- cli: minimal command dispatcher reusing the plugin layer graph
(deliberately not effect/unstable/cli — it needs platform packages)
- react subpath: plugin router (path→hash→fallback deep-link restore +
pf-ui:navigate bridge), ResultGate, sseAtom, resolvePluginBase
- theme.css: the console's violet identity packaged for plugin UIs
18 bun tests incl. the two phase-0 spikes; publish workflow mirrors
sdk-publish (tag plugin-kit-v*; 0.1.0 published manually).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>