refactor: workspace restructure — contract package + pure domain ported, tests green

- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
  domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
  imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
  hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
  gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:54:38 +02:00
parent e78df91925
commit d6cf5f3d36
30 changed files with 1938 additions and 181 deletions
+1393 -6
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,5 @@
# Resolve the `@punktfunk` scope from the Gitea npm registry (like `@punktfunk/host` itself). # Resolve the @punktfunk and @unom scopes from the Gitea npm registry for the whole
# During local development the SDK is consumed via `bun link @punktfunk/host` (the `link:` in # workspace (plugin deps + UI design system).
# package.json) so this only matters once the plugin is installed from the registry — but it's
# committed so `bun add punktfunk-plugin-rom-manager` resolves the dependency out of the box.
[install.scopes] [install.scopes]
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/" "@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@rom-manager/contract",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "The single source of truth shared by the plugin server and the UI: config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"effect": "^4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.1"
},
"devDependencies": {
"effect": "4.0.0-beta.99",
"@punktfunk/plugin-kit": "^0.1.1",
"typescript": "^5.9.3",
"@types/bun": "^1.3.0"
}
}
+85
View File
@@ -0,0 +1,85 @@
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
import { Schema } from "effect";
import {
HttpApi,
HttpApiEndpoint,
HttpApiGroup,
HttpApiSchema,
} from "effect/unstable/httpapi";
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { DetectedEmulator, EngineStatus, Platform, SyncReport } from "./domain.js";
import { ConfigInvalid, DetectFailed, ScanFailed, SyncInProgress } from "./errors.js";
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
* The UI resolves it locally by decoding through the SHARED RomConfigSchema. */
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
export type ConfigPayload = typeof ConfigPayload.Type;
/** GET /api/preview: the dry-run desired state (cached art only — no warming). */
export const PreviewResult = Schema.Struct({
entries: Schema.Array(ProviderEntry),
report: SyncReport,
});
export type PreviewResult = typeof PreviewResult.Type;
export const EmulatorsPayload = Schema.Struct({
detected: Schema.Array(DetectedEmulator),
detectedAt: Schema.NullOr(Schema.Number),
});
export type EmulatorsPayload = typeof EmulatorsPayload.Type;
export const RomManagerApi = HttpApi.make("rom-manager").add(
HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
),
).add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
)
.add(
HttpApiEndpoint.put("put", "/api/config", {
payload: Schema.Unknown,
success: ConfigPayload,
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
}),
),
).add(
HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult,
error: ScanFailed.pipe(HttpApiSchema.status(500)),
}),
),
).add(
HttpApiGroup.make("catalog")
.add(
HttpApiEndpoint.get("platforms", "/api/platforms", {
success: Schema.Array(Platform),
}),
)
.add(
HttpApiEndpoint.get("emulators", "/api/emulators", {
success: EmulatorsPayload,
}),
)
.add(
HttpApiEndpoint.post("detect", "/api/detect", {
success: EmulatorsPayload,
error: DetectFailed.pipe(HttpApiSchema.status(500)),
}),
),
).add(
HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport,
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
}),
),
);
/** The SSE status feed — outside the HttpApi (no event-stream media type in httpapi).
* Frames: `event: status`, data = EngineStatus JSON. */
export const EVENTS_PATH = "/api/events";
export const EVENTS_EVENT = "status";
+105
View File
@@ -0,0 +1,105 @@
// 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);
+108
View File
@@ -0,0 +1,108 @@
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
// the derived types keep the original domain names so the pure core reads unchanged.
import { Schema } from "effect";
export const Os = Schema.Literals(["linux", "windows", "mac"]);
export type Os = typeof Os.Type;
/** Default emulator + core for a platform (operator-overridable per platform / per game). */
export const DefaultLaunch = Schema.Struct({
emulator: Schema.String,
core: Schema.optionalKey(Schema.String),
extraArgs: Schema.optionalKey(Schema.String),
});
export type DefaultLaunch = typeof DefaultLaunch.Type;
/** A console platform: extension set, art key, default launch (design §5). */
export const Platform = Schema.Struct({
id: Schema.String,
name: Schema.String,
extensions: Schema.Array(Schema.String),
libretroSystem: Schema.optionalKey(Schema.String),
defaultLaunch: DefaultLaunch,
disc: Schema.optionalKey(Schema.Boolean),
});
export type Platform = typeof Platform.Type;
/** An emulator definition: per-OS detection, launch template, archive support. */
export const EmulatorDef = Schema.Struct({
id: Schema.String,
name: Schema.String,
detect: Schema.Struct({
linux: Schema.Array(Schema.String),
windows: Schema.Array(Schema.String),
}),
coresDir: Schema.optionalKey(
Schema.Struct({
linux: Schema.Array(Schema.String),
windows: Schema.Array(Schema.String),
}),
),
template: Schema.String,
supportsArchives: Schema.Boolean,
contested: Schema.optionalKey(Schema.Boolean),
});
export type EmulatorDef = typeof EmulatorDef.Type;
/** A detected emulator install (UI's Emulators page + launch resolution). */
export const DetectedEmulator = Schema.Struct({
id: Schema.String,
name: Schema.String,
template: Schema.String,
supportsArchives: Schema.Boolean,
contested: Schema.optionalKey(Schema.Boolean),
exeToken: Schema.String,
exePath: Schema.optionalKey(Schema.String),
appId: Schema.optionalKey(Schema.String),
via: Schema.Literals(["path", "file", "flatpak"]),
coresDir: Schema.optionalKey(Schema.String),
cores: Schema.optionalKey(Schema.Array(Schema.String)),
});
export type DetectedEmulator = typeof DetectedEmulator.Type;
/** A candidate the compute rejected, with a human reason (Library page, CLI). */
export const Skipped = Schema.Struct({
external_id: Schema.String,
title: Schema.String,
reason: Schema.String,
});
export type Skipped = typeof Skipped.Type;
/** A summary of one desired-state compute (Overview + Library pages). */
export const SyncReport = Schema.Struct({
considered: Schema.Number,
included: Schema.Number,
skipped: Schema.Array(Skipped),
excluded: Schema.Array(
Schema.Struct({ external_id: Schema.String, title: Schema.String }),
),
warnings: Schema.Array(Schema.String),
truncated: Schema.Number,
perPlatform: Schema.Record(Schema.String, Schema.Number),
overWarn: Schema.Boolean,
});
export type SyncReport = typeof SyncReport.Type;
export const LastSync = Schema.Struct({
fingerprint: Schema.String,
count: Schema.Number,
at: Schema.Number,
});
export type LastSync = typeof LastSync.Type;
/** The engine status the Overview page renders and the SSE feed streams. */
export const EngineStatus = Schema.Struct({
rootsConfigured: Schema.Number,
os: Os,
artProvider: Schema.NullOr(Schema.String),
syncing: Schema.Boolean,
lastSync: Schema.optionalKey(LastSync),
lastReport: Schema.optionalKey(SyncReport),
detectedAt: Schema.optionalKey(Schema.Number),
paths: Schema.Struct({
dir: Schema.String,
config: Schema.String,
cache: Schema.String,
}),
});
export type EngineStatus = typeof EngineStatus.Type;
+27
View File
@@ -0,0 +1,27 @@
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
// HttpApiSchema.status in api.ts.
import { Schema } from "effect";
/** PUT /api/config body failed schema validation. → 400 */
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
"ConfigInvalid",
{ issues: Schema.String },
) {}
/** A sync pass is already running (the trigger was coalesced). → 409 */
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
"SyncInProgress",
{},
) {}
/** Scan/compute failed. → 500 */
export class ScanFailed extends Schema.TaggedErrorClass<ScanFailed>()(
"ScanFailed",
{ message: Schema.String },
) {}
/** Emulator detection failed. → 500 */
export class DetectFailed extends Schema.TaggedErrorClass<DetectFailed>()(
"DetectFailed",
{ message: Schema.String },
) {}
+6
View File
@@ -0,0 +1,6 @@
// @rom-manager/contract — the shared source of truth (config schema, domain DTOs,
// HttpApi contract, API errors). Bundled into the plugin, aliased into the UI.
export * from "./api.js";
export * from "./config.js";
export * from "./domain.js";
export * from "./errors.js";
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src"]
}
+8 -46
View File
@@ -1,53 +1,15 @@
{ {
"name": "@punktfunk/plugin-rom-manager", "name": "rom-manager-workspace",
"version": "0.2.1", "private": true,
"private": false,
"type": "module", "type": "module",
"description": "Punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI.", "workspaces": ["contract", "plugin", "ui"],
"license": "MIT OR Apache-2.0",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk-plugin-rom-manager.git"
},
"keywords": [
"punktfunk",
"plugin",
"roms",
"emulators",
"retroarch",
"game-streaming"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"punktfunk-plugin-rom-manager": "./dist/cli.js"
},
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "cd contract && bun run typecheck && cd ../plugin && bun run typecheck && cd ../ui && bun run typecheck",
"test": "bun test", "test": "cd plugin && bun test",
"build": "tsc -p tsconfig.build.json", "build": "cd plugin && bun run build:all",
"build:ui": "cd ui && bun install --silent && bun run build", "check": "bunx biome check ."
"build:all": "bun run build && bun run build:ui",
"sync": "bun src/cli.ts sync",
"scan": "bun src/cli.ts scan",
"detect": "bun src/cli.ts detect",
"prepublishOnly": "bun run build:all"
},
"dependencies": {
"@punktfunk/host": "^0.1.0",
"effect": "^4.0.0-beta.98"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.2", "@biomejs/biome": "^2.5.2"
"@types/bun": "^1.3.0",
"typescript": "^5.9.3"
} }
} }
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@punktfunk/plugin-rom-manager",
"version": "0.3.0",
"private": false,
"type": "module",
"description": "Punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI. The reference plugin built on @punktfunk/plugin-kit.",
"license": "MIT OR Apache-2.0",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"repository": {
"type": "git",
"url": "https://git.unom.io/unom/punktfunk-plugin-rom-manager.git"
},
"keywords": [
"punktfunk",
"plugin",
"roms",
"emulators",
"retroarch",
"game-streaming"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./types/index.d.ts",
"bin": {
"punktfunk-plugin-rom-manager": "./dist/cli.js"
},
"files": ["dist", "types"],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test",
"build": "bun build src/index.ts src/cli.ts --target=bun --outdir dist --external effect --external '@punktfunk/*'",
"build:ui": "cd ../ui && bun run build",
"build:all": "bun run build && bun run build:ui",
"dev": "bun src/dev.ts",
"prepublishOnly": "bun run build:all"
},
"dependencies": {
"@punktfunk/host": "^0.1.2",
"@punktfunk/plugin-kit": "^0.1.1",
"effect": "^4.0.0-beta.99"
},
"devDependencies": {
"@rom-manager/contract": "workspace:*",
"@types/bun": "^1.3.0",
"typescript": "^5.9.3"
}
}
@@ -3,9 +3,9 @@
// files are named after the No-Intro name with a documented character-substitution set; we walk a // files are named after the No-Intro name with a documented character-substitution set; we walk a
// fallback ladder (exact → region-swap → bare title) and verify each candidate with one probe. // fallback ladder (exact → region-swap → bare title) and verify each candidate with one probe.
import type { ParsedTitle } from "../engine/titles.js"; import type { ParsedTitle } from "../domain/titles.js";
import type { Platform } from "../platforms.js"; import type { Platform } from "../domain/platforms.js";
import type { Artwork } from "../wire.js"; import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { ArtProvider } from "./provider.js"; import type { ArtProvider } from "./provider.js";
const BASE = "https://thumbnails.libretro.com"; const BASE = "https://thumbnails.libretro.com";
@@ -4,15 +4,24 @@
// reconcile/warm code is provider-agnostic; the choice is `config.art.provider` (default `auto` = // reconcile/warm code is provider-agnostic; the choice is `config.art.provider` (default `auto` =
// SteamGridDB when an API key is set, else libretro). // SteamGridDB when an API key is set, else libretro).
import type { Config } from "../config.js"; import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { ParsedTitle } from "../engine/titles.js"; import type { Config } from "@rom-manager/contract";
import { log } from "../log.js"; import type { Platform } from "../domain/platforms.js";
import type { Platform } from "../platforms.js"; import type { ParsedTitle } from "../domain/titles.js";
import type { ArtVerdict } from "../state.js";
import type { Artwork } from "../wire.js";
import { LibretroProvider } from "./libretro.js"; import { LibretroProvider } from "./libretro.js";
import { SteamGridDbProvider } from "./steamgriddb.js"; import { SteamGridDbProvider } from "./steamgriddb.js";
export type ArtLog = (line: string) => void;
const noopLog: ArtLog = () => {};
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with
* the provider and its matcher version so a provider switch or algorithm bump re-resolves. */
export interface ArtVerdict {
art: Artwork | null;
provider: string;
matcher: number;
}
export interface ArtProvider { export interface ArtProvider {
/** Stable id, stored in the cache verdict so a provider switch re-resolves. */ /** Stable id, stored in the cache verdict so a provider switch re-resolves. */
readonly id: string; readonly id: string;
@@ -23,20 +32,25 @@ export interface ArtProvider {
} }
/** Pick the active provider for a config, or `null` when art is disabled / unavailable. */ /** Pick the active provider for a config, or `null` when art is disabled / unavailable. */
export const selectArtProvider = (config: Config): ArtProvider | null => { export const selectArtProvider = (
config: Config,
log: ArtLog = noopLog,
): ArtProvider | null => {
if (!config.art.enabled) return null; if (!config.art.enabled) return null;
const key = config.art.steamGridDbKey?.trim(); const key = config.art.steamGridDbKey?.trim();
switch (config.art.provider) { switch (config.art.provider) {
case "libretro": case "libretro":
return new LibretroProvider(); return new LibretroProvider();
case "steamgriddb": case "steamgriddb":
if (key) return new SteamGridDbProvider(key); if (key) return new SteamGridDbProvider(key, fetch, log);
log( log(
"art: provider=steamgriddb but no API key set — no art will be fetched (set art.steamGridDbKey or use provider=auto)", "art: provider=steamgriddb but no API key set — no art will be fetched (set art.steamGridDbKey or use provider=auto)",
); );
return null; return null;
default: // "auto" default: // "auto"
return key ? new SteamGridDbProvider(key) : new LibretroProvider(); return key
? new SteamGridDbProvider(key, fetch, log)
: new LibretroProvider();
} }
}; };
@@ -76,8 +90,9 @@ export const warmArt = async (
provider: ArtProvider, provider: ArtProvider,
targets: ArtTarget[], targets: ArtTarget[],
cache: { art: Record<string, ArtVerdict> }, cache: { art: Record<string, ArtVerdict> },
opts?: { concurrency?: number }, opts?: { concurrency?: number; log?: ArtLog },
): Promise<number> => { ): Promise<number> => {
const log = opts?.log ?? noopLog;
const stale = targets.filter((t) => { const stale = targets.filter((t) => {
const v = cache.art[t.externalId]; const v = cache.art[t.externalId];
return ( return (
@@ -8,10 +8,10 @@
// the rest of the run instead of hammering the API. // the rest of the run instead of hammering the API.
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { ParsedTitle } from "../engine/titles.js"; import type { ParsedTitle } from "../domain/titles.js";
import { log } from "../log.js";
import type { Platform } from "../platforms.js"; import type { Platform } from "../domain/platforms.js";
import type { Artwork } from "../wire.js"; import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { ArtProvider } from "./provider.js"; import type { ArtProvider } from "./provider.js";
const BASE = "https://www.steamgriddb.com/api/v2"; const BASE = "https://www.steamgriddb.com/api/v2";
@@ -37,6 +37,7 @@ export class SteamGridDbProvider implements ArtProvider {
constructor( constructor(
private readonly apiKey: string, private readonly apiKey: string,
private readonly fetchImpl: SgdbFetch = fetch, private readonly fetchImpl: SgdbFetch = fetch,
private readonly log: (line: string) => void = () => {},
) { ) {
// The key fingerprint scopes cached verdicts to this key: change the key → re-resolve, without // The key fingerprint scopes cached verdicts to this key: change the key → re-resolve, without
// ever storing the key itself in the cache file. // ever storing the key itself in the cache file.
@@ -52,7 +53,7 @@ export class SteamGridDbProvider implements ArtProvider {
}); });
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
this.authFailed = true; this.authFailed = true;
log( this.log(
"art: SteamGridDB rejected the API key (401/403) — check art.steamGridDbKey", "art: SteamGridDB rejected the API key (401/403) — check art.steamGridDbKey",
); );
return null; return null;
@@ -98,7 +99,13 @@ export class SteamGridDbProvider implements ArtProvider {
this.getSafe(`/logos/game/${game.id}`) as Promise<SgdbImage[]>, this.getSafe(`/logos/game/${game.id}`) as Promise<SgdbImage[]>,
]); ]);
const art: Artwork = {}; // Built mutable, returned as the (readonly) wire type.
const art: {
portrait?: string;
hero?: string;
logo?: string;
header?: string;
} = {};
const portrait = pickGrid(grids, (i) => i.height > i.width, [ const portrait = pickGrid(grids, (i) => i.height > i.width, [
[600, 900], [600, 900],
[660, 930], [660, 930],
@@ -5,9 +5,9 @@
// stretch item. // stretch item.
import * as nodePath from "node:path"; import * as nodePath from "node:path";
import type { DetectedEmulator } from "../emulators.js"; import type { DetectedEmulator } from "./emulators.js";
import { type Os, quoteFor } from "../quote.js"; import { type Os, quoteFor } from "./quote.js";
import type { PrepCmd } from "../wire.js"; import type { PrepStep } from "@punktfunk/plugin-kit/wire";
/** A command that always exits 0, for `prep.do`. */ /** A command that always exits 0, for `prep.do`. */
const noop = (os: Os): string => (os === "windows" ? "cd ." : "true"); const noop = (os: Os): string => (os === "windows" ? "cd ." : "true");
@@ -20,7 +20,7 @@ const noop = (os: Os): string => (os === "windows" ? "cd ." : "true");
export const killPrep = ( export const killPrep = (
det: DetectedEmulator | undefined, det: DetectedEmulator | undefined,
os: Os, os: Os,
): PrepCmd | null => { ): PrepStep | null => {
if (!det) return null; if (!det) return null;
if (det.via === "flatpak" && det.appId) { if (det.via === "flatpak" && det.appId) {
return { do: noop(os), undo: `flatpak kill ${det.appId}` }; return { do: noop(os), undo: `flatpak kill ${det.appId}` };
@@ -6,35 +6,17 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
// The DefaultLaunch/EmulatorDef/DetectedEmulator TYPES are contract-owned (shared with
// the UI); this module owns the DATA and the detection logic (DetectEnv stays an
// injectable IO seam local to the plugin).
import type {
DefaultLaunch,
DetectedEmulator,
EmulatorDef,
} from "@rom-manager/contract";
import { type Os, quoteFor } from "./quote.js"; import { type Os, quoteFor } from "./quote.js";
/** A platform's default (or a per-platform/per-game override) launcher choice. */ export type { DefaultLaunch, DetectedEmulator, EmulatorDef };
export interface DefaultLaunch {
/** Emulator id (`retroarch`, `dolphin`, …) — a key into {@link EMULATORS} or a `custom-*` template. */
emulator: string;
/** RetroArch core base name (`snes9x`) — required for `retroarch`, ignored otherwise. */
core?: string;
/** Operator-typed extra args, appended verbatim (trusted). */
extraArgs?: string;
}
export interface EmulatorDef {
id: string;
name: string;
/**
* How to find the emulator, tried in order. An entry is one of: `"flatpak:<app-id>"`, a path
* (contains a separator, `~`, or an env var like `%APPDATA%`), or a bare PATH command.
*/
detect: { linux: string[]; windows: string[] };
/** RetroArch cores directories to scan for available cores (per-OS), first existing wins. */
coresDir?: { linux: string[]; windows: string[] };
/** Launch template — `{exe}` (verbatim launcher), `{core}`/`{rom}` (quoted). */
template: string;
/** Whether the emulator can load `.zip`/`.7z` directly (archive gating, design §5). */
supportsArchives: boolean;
/** Contested legal status (design Q4): shipped as a built-in def but flagged in the UI. */
contested?: boolean;
}
export const EMULATORS: EmulatorDef[] = [ export const EMULATORS: EmulatorDef[] = [
{ {
@@ -186,7 +168,7 @@ export const emulatorById = (id: string): EmulatorDef | undefined =>
/** Merge built-in emulators with operator-defined ones (config `emulators[]`, `custom-*` templates). */ /** Merge built-in emulators with operator-defined ones (config `emulators[]`, `custom-*` templates). */
export const resolveEmulators = ( export const resolveEmulators = (
overrides?: EmulatorDef[], overrides?: ReadonlyArray<EmulatorDef>,
): Map<string, EmulatorDef> => { ): Map<string, EmulatorDef> => {
const merged = new Map(BY_ID); const merged = new Map(BY_ID);
for (const e of overrides ?? []) merged.set(e.id, e); for (const e of overrides ?? []) merged.set(e.id, e);
@@ -210,25 +192,6 @@ export interface DetectEnv {
expandPath(p: string): string; expandPath(p: string): string;
} }
export interface DetectedEmulator {
id: string;
name: string;
template: string;
supportsArchives: boolean;
contested?: boolean;
/** The `{exe}` token, verbatim: a per-OS-quoted absolute path, or `flatpak run <app-id>`. */
exeToken: string;
/** Raw absolute executable path (path/file detection) — for deriving a kill target ("close on end"). */
exePath?: string;
/** Flatpak app id (flatpak detection) — for `flatpak kill <appId>`. */
appId?: string;
/** How it was located (UI). */
via: "path" | "file" | "flatpak";
/** RetroArch cores dir that exists, and the core base names found in it. */
coresDir?: string;
cores?: string[];
}
const FLATPAK_ID_RE = /^[A-Za-z][A-Za-z0-9._-]+$/; const FLATPAK_ID_RE = /^[A-Za-z][A-Za-z0-9._-]+$/;
const looksLikePath = (s: string): boolean => const looksLikePath = (s: string): boolean =>
@@ -250,7 +213,7 @@ const coreSuffix = (o: Os): string => (o === "windows" ? ".dll" : ".so");
/** Scan cores directories for `<name>_libretro.<ext>` files → sorted core base names. */ /** Scan cores directories for `<name>_libretro.<ext>` files → sorted core base names. */
const scanCores = ( const scanCores = (
env: DetectEnv, env: DetectEnv,
dirs: string[], dirs: ReadonlyArray<string>,
): { coresDir?: string; cores: string[] } => { ): { coresDir?: string; cores: string[] } => {
const suffix = `_libretro${coreSuffix(env.os)}`; const suffix = `_libretro${coreSuffix(env.os)}`;
for (const raw of dirs) { for (const raw of dirs) {
@@ -313,7 +276,10 @@ export const detectEmulator = (
} }
if (!exeToken || !via) return null; if (!exeToken || !via) return null;
const found: DetectedEmulator = { // Built mutable, returned as the (readonly) contract type.
const found: {
-readonly [K in keyof DetectedEmulator]: DetectedEmulator[K];
} = {
id: def.id, id: def.id,
name: def.name, name: def.name,
template: def.template, template: def.template,
@@ -3,9 +3,9 @@
// either succeeds (a command string) or is skipped with a reason the UI/CLI surfaces; `warnings` // either succeeds (a command string) or is skipped with a reason the UI/CLI surfaces; `warnings`
// carries non-fatal notes (e.g. the RetroArch core isn't installed yet). // carries non-fatal notes (e.g. the RetroArch core isn't installed yet).
import type { DetectedEmulator, EmulatorDef } from "../emulators.js"; import type { DetectedEmulator, EmulatorDef } from "./emulators.js";
import type { Platform } from "../platforms.js"; import type { Platform } from "./platforms.js";
import { type Os, renderCommand } from "../quote.js"; import { type Os, renderCommand } from "./quote.js";
import type { RomCandidate } from "./scanner.js"; import type { RomCandidate } from "./scanner.js";
/** The effective emulator choice for a title after layering platform default → override → per-game. */ /** The effective emulator choice for a title after layering platform default → override → per-game. */
@@ -6,28 +6,10 @@
// scanner's unit of work being a **ROM root** `(directory, platform)`: a `.iso` under a `gamecube` // scanner's unit of work being a **ROM root** `(directory, platform)`: a `.iso` under a `gamecube`
// root is a GameCube disc, under a `ps2` root a PS2 disc. Extensions here are lowercase, dot-prefixed. // root is a GameCube disc, under a `ps2` root a PS2 disc. Extensions here are lowercase, dot-prefixed.
import type { DefaultLaunch } from "./emulators.js"; // The Platform TYPE is contract-owned (shared with the UI); this module owns the DATA.
import type { Platform } from "@rom-manager/contract";
export interface Platform { export type { Platform };
/** Stable kebab-case id — the first segment of every `external_id` (`snes/Chrono Trigger.sfc`). */
id: string;
/** Human-readable name (console nav, UI). */
name: string;
/** Lowercase, dot-prefixed extensions that mark a file as this platform's ROM. */
extensions: string[];
/**
* The libretro-thumbnails system directory name (design §7) the box-art source key. `undefined`
* for platforms libretro has no thumbnail set for (Switch, Xbox), which simply get no cover in v1.
*/
libretroSystem?: string;
/** Default emulator + core for this platform (operator-overridable per platform / per game). */
defaultLaunch: DefaultLaunch;
/**
* Disc-based platform: `.cue`/`.gdi`/`.m3u` folding applies and loose `.bin` track files are
* hidden behind their sheet (design §5, M2). Cartridge platforms leave this false.
*/
disc?: boolean;
}
/** The built-in platform set (~25 at launch). Arcade (MAME/FBNeo romset versioning) is deferred (D6). */ /** The built-in platform set (~25 at launch). Arcade (MAME/FBNeo romset versioning) is deferred (D6). */
export const PLATFORMS: Platform[] = [ export const PLATFORMS: Platform[] = [
@@ -244,7 +226,7 @@ export const platformById = (id: string): Platform | undefined => BY_ID.get(id);
/** Merge the built-in registry with operator-defined platforms (config `platforms[]` override by id). */ /** Merge the built-in registry with operator-defined platforms (config `platforms[]` override by id). */
export const resolvePlatforms = ( export const resolvePlatforms = (
overrides?: Platform[], overrides?: ReadonlyArray<Platform>,
): Map<string, Platform> => { ): Map<string, Platform> => {
const merged = new Map(BY_ID); const merged = new Map(BY_ID);
for (const p of overrides ?? []) merged.set(p.id, p); for (const p of overrides ?? []) merged.set(p.id, p);
@@ -1,16 +1,16 @@
// The desired-state compute (design §8) — a **pure function** of `config × scan results × detection × // The desired-state compute (design §8) — a **pure function** of `config × scan results × detection ×
// art cache → ProviderEntryInput[]`, unit-testable with no host and no filesystem. The engine // art cache → ProviderEntry[]`, unit-testable with no host and no filesystem. The engine
// orchestrator (index.ts / cli.ts) does the I/O (scan, detect, art-probe) and hands the results here. // orchestrator (index.ts / cli.ts) does the I/O (scan, detect, art-probe) and hands the results here.
// //
// `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the // `enumerateTitles` is factored out so the orchestrator can learn which titles need art (and warm the
// cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules. // cache) BEFORE the final `computeDesired`, without re-implementing the exclude/dedupe rules.
import type { Config, GameOverride } from "../config.js"; import type { Config, GameOverride } from "@rom-manager/contract";
import { type DetectedEmulator, resolveEmulators } from "../emulators.js"; import { type DetectedEmulator, resolveEmulators } from "./emulators.js";
import { type Platform, resolvePlatforms } from "../platforms.js"; import { type Platform, resolvePlatforms } from "./platforms.js";
import type { Os } from "../quote.js"; import type { Os } from "./quote.js";
import type { ArtVerdict } from "../state.js"; import type { ArtVerdict } from "../art/provider.js";
import type { Artwork, ProviderEntryInput } from "../wire.js"; import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { killPrep } from "./close.js"; import { killPrep } from "./close.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.js"; import { type EffectiveLaunch, resolveLaunch } from "./launch.js";
import type { RomCandidate } from "./scanner.js"; import type { RomCandidate } from "./scanner.js";
@@ -56,7 +56,7 @@ export interface SyncReport {
} }
export interface DesiredResult { export interface DesiredResult {
entries: ProviderEntryInput[]; entries: ProviderEntry[];
report: SyncReport; report: SyncReport;
} }
@@ -209,7 +209,7 @@ export const computeDesired = (params: {
const { survivors, skipped, excluded } = enumerateTitles(config, scan); const { survivors, skipped, excluded } = enumerateTitles(config, scan);
const warnings: string[] = []; const warnings: string[] = [];
const entries: ProviderEntryInput[] = []; const entries: ProviderEntry[] = [];
const perPlatform: Record<string, number> = {}; const perPlatform: Record<string, number> = {};
for (const p of survivors) { for (const p of survivors) {
const launch = effectiveLaunch(p.platform, config, p.override); const launch = effectiveLaunch(p.platform, config, p.override);
@@ -232,7 +232,10 @@ export const computeDesired = (params: {
} }
for (const w of resolution.warnings) warnings.push(`${title}: ${w}`); for (const w of resolution.warnings) warnings.push(`${title}: ${w}`);
const entry: ProviderEntryInput = { // Built mutable, collected as the (readonly) wire type.
const entry: {
-readonly [K in keyof ProviderEntry]: ProviderEntry[K];
} = {
external_id: p.externalId, external_id: p.externalId,
title, title,
launch: { kind: "command", value: resolution.value }, launch: { kind: "command", value: resolution.value },
@@ -5,7 +5,7 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as nodePath from "node:path"; import * as nodePath from "node:path";
import type { Platform } from "../platforms.js"; import type { Platform } from "./platforms.js";
/** A directory entry the walker sees. */ /** A directory entry the walker sees. */
export interface DirEnt { export interface DirEnt {
@@ -122,7 +122,7 @@ export const dedupeKey = (parsed: ParsedTitle): string =>
*/ */
export const pickBestRelease = <T extends { parsed: ParsedTitle }>( export const pickBestRelease = <T extends { parsed: ParsedTitle }>(
candidates: T[], candidates: T[],
regionPriority: string[], regionPriority: ReadonlyArray<string>,
): T => { ): T => {
const rank = (c: T): number => { const rank = (c: T): number => {
const idx = c.parsed.regions const idx = c.parsed.regions
@@ -3,8 +3,8 @@ import {
type DetectEnv, type DetectEnv,
detectEmulator, detectEmulator,
emulatorById, emulatorById,
} from "../src/emulators.js"; } from "../src/domain/emulators.js";
import type { Os } from "../src/quote.js"; import type { Os } from "../src/domain/quote.js";
const env = (o: { const env = (o: {
os?: Os; os?: Os;
@@ -5,8 +5,8 @@ import {
LibretroProvider, LibretroProvider,
sanitizeName, sanitizeName,
} from "../src/art/libretro.js"; } from "../src/art/libretro.js";
import { parseTitle } from "../src/engine/titles.js"; import { parseTitle } from "../src/domain/titles.js";
import { platformById } from "../src/platforms.js"; import { platformById } from "../src/domain/platforms.js";
const snes = platformById("snes")!; const snes = platformById("snes")!;
const nswitch = platformById("switch")!; const nswitch = platformById("switch")!;
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { quotePosix, quoteWindows, renderCommand } from "../src/quote.js"; import { quotePosix, quoteWindows, renderCommand } from "../src/domain/quote.js";
describe("quotePosix", () => { describe("quotePosix", () => {
test("wraps a plain path in single quotes", () => { test("wraps a plain path in single quotes", () => {
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { resolveConfig } from "../src/config.js"; import { resolveConfig } from "@rom-manager/contract";
import type { DetectedEmulator } from "../src/emulators.js"; import type { DetectedEmulator } from "../src/domain/emulators.js";
import { computeDesired } from "../src/engine/reconcile.js"; import { computeDesired } from "../src/domain/reconcile.js";
import type { RomCandidate } from "../src/engine/scanner.js"; import type { RomCandidate } from "../src/domain/scanner.js";
import type { ArtVerdict } from "../src/state.js"; import type { ArtVerdict } from "../src/art/provider.js";
const RA: DetectedEmulator = { const RA: DetectedEmulator = {
id: "retroarch", id: "retroarch",
@@ -29,7 +29,7 @@ const cand = (rel: string, over: Partial<RomCandidate> = {}): RomCandidate => ({
const base = { roots: [{ dir: "/roms/snes", platform: "snes" }] }; const base = { roots: [{ dir: "/roms/snes", platform: "snes" }] };
const run = ( const run = (
raw: Parameters<typeof resolveConfig>[0], raw: Record<string, unknown>,
scan: RomCandidate[], scan: RomCandidate[],
art: Record<string, ArtVerdict> = {}, art: Record<string, ArtVerdict> = {},
detected = [RA], detected = [RA],
@@ -4,8 +4,8 @@ import {
makeExcluder, makeExcluder,
type ScanFs, type ScanFs,
scanRoot, scanRoot,
} from "../src/engine/scanner.js"; } from "../src/domain/scanner.js";
import { platformById } from "../src/platforms.js"; import { platformById } from "../src/domain/platforms.js";
/** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */ /** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */
const synthFs = (files: Record<string, string>): ScanFs => { const synthFs = (files: Record<string, string>): ScanFs => {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { SteamGridDbProvider } from "../src/art/steamgriddb.js"; import { SteamGridDbProvider } from "../src/art/steamgriddb.js";
import { parseTitle } from "../src/engine/titles.js"; import { parseTitle } from "../src/domain/titles.js";
import { platformById } from "../src/platforms.js"; import { platformById } from "../src/domain/platforms.js";
const snes = platformById("snes")!; const snes = platformById("snes")!;
@@ -3,7 +3,7 @@ import {
dedupeKey, dedupeKey,
parseTitle, parseTitle,
pickBestRelease, pickBestRelease,
} from "../src/engine/titles.js"; } from "../src/domain/titles.js";
describe("parseTitle", () => { describe("parseTitle", () => {
test("strips No-Intro tags and keeps region + revision metadata", () => { test("strips No-Intro tags and keeps region + revision metadata", () => {
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src", "test"]
}