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:
+3
-4
@@ -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/"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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 },
|
||||||
|
) {}
|
||||||
@@ -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";
|
||||||
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user