Files
enricobuehler 899028e20f feat: Effect services + HttpApi implementation + kit entry/CLI/dev server
- RomConfig/RomCache on the kit's ConfigService/CacheStore; RomSync wires the
  pure domain into the kit SyncEngine (poll/watch/coalesce/fingerprint) and
  ProviderClient; EngineStatus assembly shared by REST + SSE
- makeApi: HttpApiBuilder groups over live service values (handler runtime
  shares the plugin runtime's singletons) + sseRoute status feed
- index.ts: definePluginKit entry (async-main boundary); headless-first (UI
  serve failure logged, engine continues)
- cli.ts on the kit dispatcher: scan/detect/preview offline, sync/uninstall
  online; set-password is gone with the standalone server
- dev.ts: auth-free loopback API server (:5885) — the dev:live proxy target;
  never bundled
- verified live host-less: raw config round-trip on disk, status/platforms/
  preview endpoints, soft-fail reconcile without a host
- CI: workspace install, per-package typecheck, publish from plugin/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:04:25 +02:00

103 lines
4.1 KiB
TypeScript

// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
//
// Dropped vs 0.2.x: `ui.*` (the standalone password server is gone — console surface +
// CLI only) and `devEntry` (dev flag out of the prod shape; use `preview` instead).
// Stale keys in existing files are tolerated on decode and dropped by the next save.
import { Effect, Schema } from "effect";
import { EmulatorDef, Platform } from "./domain.js";
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
}),
);
/** A ROM root: a directory paired with the platform its files belong to (design §5). */
export const RomRoot = Schema.Struct({
dir: Schema.String,
platform: Schema.String,
excludes: Schema.optionalKey(Schema.Array(Schema.String)),
});
export type RomRoot = typeof RomRoot.Type;
/** A per-platform launch override — replaces the platform's built-in default. */
export const PlatformLaunch = Schema.Struct({
emulator: Schema.String,
core: Schema.optionalKey(Schema.String),
extraArgs: Schema.optionalKey(Schema.String),
});
export type PlatformLaunch = typeof PlatformLaunch.Type;
/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */
export const GameOverride = Schema.Struct({
exclude: Schema.optionalKey(Schema.Boolean),
emulator: Schema.optionalKey(Schema.String),
core: Schema.optionalKey(Schema.String),
extraArgs: Schema.optionalKey(Schema.String),
title: Schema.optionalKey(Schema.String),
art: Schema.optionalKey(Schema.String),
});
export type GameOverride = typeof GameOverride.Type;
export const ArtProviderChoice = Schema.Literals([
"auto",
"steamgriddb",
"libretro",
]);
export type ArtProviderChoice = typeof ArtProviderChoice.Type;
export const SyncOptions = Schema.Struct({
/** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is primary). */
pollMinutes: withDefault(Schema.Number, 15),
watch: withDefault(Schema.Boolean, true),
debounceMs: withDefault(Schema.Number, 2000),
/** Platform ids to region-dedupe ("one entry per title", design §5). */
dedupeRegions: withDefault(Schema.Array(Schema.String), []),
regionPriority: withDefault(Schema.Array(Schema.String), [
"USA",
"World",
"Europe",
"Japan",
]),
warnEntries: withDefault(Schema.Number, 2000),
maxEntries: withDefault(Schema.Number, 5000),
closeOnEnd: withDefault(Schema.Boolean, false),
});
export type SyncOptions = typeof SyncOptions.Type;
export const ArtOptions = Schema.Struct({
enabled: withDefault(Schema.Boolean, true),
provider: withDefault(ArtProviderChoice, "auto"),
steamGridDbKey: Schema.optionalKey(Schema.String),
});
export type ArtOptions = typeof ArtOptions.Type;
export const RomConfigSchema = Schema.Struct({
roots: withDefault(Schema.Array(RomRoot), []),
platformLaunch: withDefault(Schema.Record(Schema.String, PlatformLaunch), {}),
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
/** Operator-added / overriding platform defs (merged over built-ins by id). */
platforms: Schema.optionalKey(Schema.Array(Platform)),
/** Operator-added / overriding emulator defs (merged over built-ins by id). */
emulators: Schema.optionalKey(Schema.Array(EmulatorDef)),
sync: withDefault(SyncOptions, {}),
art: withDefault(ArtOptions, {}),
});
/** The fully-resolved config the engine consumes. */
export type Config = typeof RomConfigSchema.Type;
/** The on-disk config as authored (all optional). */
export type RawConfig = typeof RomConfigSchema.Encoded;
/**
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
* ConfigService (Effect + typed errors) instead.
*/
export const resolveConfig = (raw: unknown): Config =>
Schema.decodeUnknownSync(RomConfigSchema)(raw);