diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1d7d7fd..ff52a26 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,7 +1,8 @@ -# CI for @punktfunk/plugin-rom-manager (Gitea Actions). Mirrors the main repo's sdk-publish.yml: +# CI for the rom-manager workspace (Gitea Actions). Mirrors the main repo's TS workflows: # ubuntu-24.04 runner + the oven/bun:1 container, auth via the shared REGISTRY_TOKEN secret. -# Installs resolve @punktfunk/* + @unom/* (the SPA's design system) from the Gitea registry via the -# bunfig scope maps; `effect` comes from npm. Publish runs on a `v*` tag. +# One root install covers all workspaces (contract + plugin + ui); @punktfunk/* and +# @unom/* resolve from the Gitea registry via the root bunfig scope maps, effect and +# @effect/atom-react from npm. Publish runs on a `v*` tag, from plugin/. name: CI on: @@ -28,20 +29,27 @@ jobs: run: | test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" - - name: Install + - name: Install (workspace) run: bun install --frozen-lockfile - name: Lint & format run: bunx biome check - - name: Typecheck (backend) + - name: Typecheck (contract) + working-directory: contract run: bunx tsc --noEmit - - name: Test (engine) + - name: Typecheck (plugin) + working-directory: plugin + run: bunx tsc --noEmit + - name: Test (domain + services) + working-directory: plugin run: bun test - - name: Build backend + SPA + - name: Build backend bundle + SPA + working-directory: plugin run: bun run build:all - name: Typecheck (UI) working-directory: ui run: bunx tsc --noEmit - name: Sanity — plugin default export is a valid PluginDef + working-directory: plugin run: | bun -e 'import plugin from "./dist/index.js"; if (plugin?.name !== "rom-manager" || typeof plugin?.main !== "function") { console.error("bad default export", plugin); process.exit(1); } console.log("ok:", plugin.name)' @@ -62,12 +70,14 @@ jobs: run: | test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" - - name: Install + - name: Install (workspace) run: bun install --frozen-lockfile - name: Tag matches package version + working-directory: plugin run: | TAG="${GITHUB_REF_NAME#v}" PKG="$(node -p "require('./package.json').version")" test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; } - name: Publish (prepublishOnly builds backend + SPA) + working-directory: plugin run: bun publish diff --git a/config.example.json b/config.example.json deleted file mode 100644 index 6f1c5a4..0000000 --- a/config.example.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "roots": [ - { "dir": "/mnt/roms/snes", "platform": "snes" }, - { "dir": "/mnt/roms/gba", "platform": "gba" }, - { - "dir": "/mnt/roms/ps1", - "platform": "ps1", - "excludes": ["*.sav", "bios/**"] - } - ], - "platformLaunch": { - "ps1": { "emulator": "duckstation" } - }, - "gameOverrides": { - "snes/Weird Homebrew (USA).sfc": { "exclude": true }, - "gba/Pokemon - Emerald Version (USA, Europe).gba": { - "title": "Pokémon Emerald" - } - }, - "sync": { - "pollMinutes": 15, - "watch": true, - "debounceMs": 2000, - "dedupeRegions": ["snes"], - "maxEntries": 5000, - "closeOnEnd": false - }, - "art": { - "enabled": true, - "provider": "auto", - "steamGridDbKey": "" - }, - "ui": { - "standalone": false, - "port": 47993, - "bind": "127.0.0.1" - } -} diff --git a/contract/src/api.ts b/contract/src/api.ts index 606f016..7ba4cc6 100644 --- a/contract/src/api.ts +++ b/contract/src/api.ts @@ -1,5 +1,7 @@ // 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 { ProviderEntry } from "@punktfunk/plugin-kit/wire"; import { Schema } from "effect"; import { HttpApi, @@ -7,9 +9,18 @@ import { 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"; +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. */ @@ -29,55 +40,60 @@ export const EmulatorsPayload = Schema.Struct({ }); 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)), +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("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("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)), }), ), -).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. */ diff --git a/contract/src/config.ts b/contract/src/config.ts index dedcf79..da156c5 100644 --- a/contract/src/config.ts +++ b/contract/src/config.ts @@ -9,10 +9,7 @@ import { Effect, Schema } from "effect"; import { EmulatorDef, Platform } from "./domain.js"; -const withDefault = ( - schema: S, - value: S["Encoded"], -) => +const withDefault = (schema: S, value: S["Encoded"]) => schema.pipe( Schema.withDecodingDefaultKey(Effect.succeed(value), { encodingStrategy: "omit", diff --git a/contract/src/domain.ts b/contract/src/domain.ts index 80e1119..9325b73 100644 --- a/contract/src/domain.ts +++ b/contract/src/domain.ts @@ -2,7 +2,8 @@ // 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"]); +/** Host OS as the launch/quoting domain sees it (macOS hosts use the linux lane). */ +export const Os = Schema.Literals(["linux", "windows"]); export type Os = typeof Os.Type; /** Default emulator + core for a platform (operator-overridable per platform / per game). */ @@ -50,13 +51,13 @@ export const DetectedEmulator = Schema.Struct({ name: Schema.String, template: Schema.String, supportsArchives: Schema.Boolean, - contested: Schema.optionalKey(Schema.Boolean), + contested: Schema.optional(Schema.Boolean), exeToken: Schema.String, - exePath: Schema.optionalKey(Schema.String), - appId: Schema.optionalKey(Schema.String), + exePath: Schema.optional(Schema.String), + appId: Schema.optional(Schema.String), via: Schema.Literals(["path", "file", "flatpak"]), - coresDir: Schema.optionalKey(Schema.String), - cores: Schema.optionalKey(Schema.Array(Schema.String)), + coresDir: Schema.optional(Schema.String), + cores: Schema.optional(Schema.Array(Schema.String)), }); export type DetectedEmulator = typeof DetectedEmulator.Type; @@ -96,9 +97,9 @@ export const EngineStatus = Schema.Struct({ os: Os, artProvider: Schema.NullOr(Schema.String), syncing: Schema.Boolean, - lastSync: Schema.optionalKey(LastSync), - lastReport: Schema.optionalKey(SyncReport), - detectedAt: Schema.optionalKey(Schema.Number), + lastSync: Schema.optional(LastSync), + lastReport: Schema.optional(SyncReport), + detectedAt: Schema.optional(Schema.Number), paths: Schema.Struct({ dir: Schema.String, config: Schema.String, diff --git a/plugin/package.json b/plugin/package.json index 1bce7c8..69b3727 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -24,7 +24,10 @@ "bin": { "punktfunk-plugin-rom-manager": "./dist/cli.js" }, - "files": ["dist", "types"], + "files": [ + "dist", + "types" + ], "publishConfig": { "registry": "https://git.unom.io/api/packages/unom/npm/" }, diff --git a/plugin/src/art/libretro.ts b/plugin/src/art/libretro.ts index 2ca267e..4efc4f8 100644 --- a/plugin/src/art/libretro.ts +++ b/plugin/src/art/libretro.ts @@ -3,9 +3,9 @@ // 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. -import type { ParsedTitle } from "../domain/titles.js"; -import type { Platform } from "../domain/platforms.js"; import type { Artwork } from "@punktfunk/plugin-kit/wire"; +import type { Platform } from "../domain/platforms.js"; +import type { ParsedTitle } from "../domain/titles.js"; import type { ArtProvider } from "./provider.js"; const BASE = "https://thumbnails.libretro.com"; diff --git a/plugin/src/art/steamgriddb.ts b/plugin/src/art/steamgriddb.ts index 35ad98e..2de0f14 100644 --- a/plugin/src/art/steamgriddb.ts +++ b/plugin/src/art/steamgriddb.ts @@ -8,10 +8,10 @@ // the rest of the run instead of hammering the API. import { createHash } from "node:crypto"; -import type { ParsedTitle } from "../domain/titles.js"; +import type { Artwork } from "@punktfunk/plugin-kit/wire"; import type { Platform } from "../domain/platforms.js"; -import type { Artwork } from "@punktfunk/plugin-kit/wire"; +import type { ParsedTitle } from "../domain/titles.js"; import type { ArtProvider } from "./provider.js"; const BASE = "https://www.steamgriddb.com/api/v2"; diff --git a/plugin/src/cli.ts b/plugin/src/cli.ts new file mode 100644 index 0000000..63cab12 --- /dev/null +++ b/plugin/src/cli.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +// Ops CLI on the kit's dispatcher — the same layer graph as the plugin entry, so every +// command sees the exact services the runner runs. scan/detect/preview work offline; +// sync/uninstall talk to the host. +import { + type CliCommand, + ProviderClient, + runPluginCli, +} from "@punktfunk/plugin-kit"; +import { Effect } from "effect"; +import pkg from "../package.json" with { type: "json" }; +import { PLUGIN_NAME } from "./index.js"; +import { type RomServices, romLayer } from "./layer.js"; +import { PROVIDER_ID, RomSync } from "./services/engine.js"; + +const print = (line: string) => Effect.sync(() => console.log(line)); + +const commands: Record> = { + scan: { + summary: "Scan configured ROM roots and list candidates", + offline: true, + run: () => + Effect.gen(function* () { + const sync = yield* RomSync; + const { report } = yield* sync.preview; + yield* print( + `${report.considered} candidate(s), ${report.included} would be included`, + ); + for (const [platform, n] of Object.entries(report.perPlatform)) { + yield* print(` ${platform}: ${n}`); + } + }), + }, + detect: { + summary: "Probe for installed emulators", + offline: true, + run: () => + Effect.gen(function* () { + const sync = yield* RomSync; + const detected = yield* sync.detect(true); + for (const d of detected) { + yield* print( + `${d.id.padEnd(14)} ${d.via.padEnd(8)} ${d.exePath ?? d.exeToken}${d.cores ? ` (${d.cores.length} cores)` : ""}`, + ); + } + if (detected.length === 0) yield* print("no emulators found"); + }), + }, + preview: { + summary: "Dry-run: show the entries a sync would reconcile", + offline: true, + run: () => + Effect.gen(function* () { + const sync = yield* RomSync; + const { entries, report } = yield* sync.preview; + for (const e of entries) yield* print(`${e.external_id} ${e.title}`); + for (const s of report.skipped) { + yield* print(`SKIP ${s.external_id}: ${s.reason}`); + } + yield* print( + `${report.included} included, ${report.skipped.length} skipped`, + ); + }), + }, + sync: { + summary: "Reconcile the library provider now", + run: () => + Effect.gen(function* () { + const sync = yield* RomSync; + const outcome = yield* sync.engine.sync("manual"); + yield* print( + outcome._tag === "AlreadyRunning" + ? "a sync is already running" + : `${outcome._tag}: ${outcome.report.included} entries`, + ); + }), + }, + uninstall: { + summary: "Remove every rom-manager entry from the host library", + run: () => + Effect.gen(function* () { + const provider = yield* ProviderClient; + yield* provider.remove(PROVIDER_ID); + yield* print("provider entries removed"); + }), + }, +}; + +await runPluginCli({ + def: { + name: PLUGIN_NAME, + version: pkg.version, + layer: romLayer, + main: Effect.void, + }, + commands, +}); diff --git a/plugin/src/dev.ts b/plugin/src/dev.ts new file mode 100644 index 0000000..e2aec7e --- /dev/null +++ b/plugin/src/dev.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +// Dev-only API server: the plugin's HttpApi on a plain loopback port (:5885), no auth, +// no static files — the `bun run dev:live` proxy target for the Vite UI. Connects to a +// running host when one is up (full sync path); otherwise runs host-less (scan, preview, +// detect, and config all work; reconcile fails softly in the engine's safeSync). +// This is NOT part of the shipped plugin (never bundled into dist/index.js or cli.js). +import { connect, type Punktfunk } from "@punktfunk/host"; +import { + hostClientFromFacade, + loggingLayer, + pluginInfoLayer, +} from "@punktfunk/plugin-kit"; +import { Effect, Layer, ManagedRuntime } from "effect"; +import { HttpRouter } from "effect/unstable/http"; +import pkg from "../package.json" with { type: "json" }; +import { PLUGIN_NAME } from "./index.js"; +import { romLayer } from "./layer.js"; +import { makeApi } from "./services/api.js"; +import { RomCache } from "./services/cache.js"; +import { RomConfig } from "./services/config.js"; +import { RomSync } from "./services/engine.js"; + +const PORT = Number(process.env.ROM_MANAGER_DEV_PORT ?? 5885); + +const offlineFacade = (): Punktfunk => + ({ + request: async (method: string, path: string) => { + throw new Error(`dev: no host running (tried ${method} ${path})`); + }, + close: () => {}, + }) as unknown as Punktfunk; + +const pf = await connect().catch(() => { + console.log("dev: no punktfunk host reachable — running host-less"); + return offlineFacade(); +}); + +const base = Layer.mergeAll( + hostClientFromFacade(pf), + pluginInfoLayer({ name: PLUGIN_NAME, version: pkg.version }), + loggingLayer(PLUGIN_NAME), +); +const rt = ManagedRuntime.make(Layer.provideMerge(romLayer, base)); + +// No Effect.scoped here: the engine's loops belong to the runtime's layer scope and +// must outlive this acquisition effect (they close on rt.dispose()). +const { handler, dispose } = await rt.runPromise( + Effect.gen(function* () { + const sync = yield* RomSync; + const config = yield* RomConfig; + const cache = yield* RomCache; + yield* sync.engine.start; + return HttpRouter.toWebHandler(makeApi({ sync, config, cache })); + }), +); + +const server = Bun.serve({ + hostname: "127.0.0.1", + port: PORT, + idleTimeout: 0, // SSE + fetch: (req) => handler(req), +}); +console.log( + `rom-manager dev API on http://127.0.0.1:${server.port}/api/status`, +); + +process.once("SIGINT", () => { + void dispose() + .then(() => rt.dispose()) + .finally(() => process.exit(0)); +}); diff --git a/plugin/src/domain/close.ts b/plugin/src/domain/close.ts index 94b9f21..122a07a 100644 --- a/plugin/src/domain/close.ts +++ b/plugin/src/domain/close.ts @@ -5,9 +5,9 @@ // stretch item. import * as nodePath from "node:path"; +import type { PrepStep } from "@punktfunk/plugin-kit/wire"; import type { DetectedEmulator } from "./emulators.js"; import { type Os, quoteFor } from "./quote.js"; -import type { PrepStep } from "@punktfunk/plugin-kit/wire"; /** A command that always exits 0, for `prep.do`. */ const noop = (os: Os): string => (os === "windows" ? "cd ." : "true"); diff --git a/plugin/src/domain/reconcile.ts b/plugin/src/domain/reconcile.ts index 363f73f..2f83140 100644 --- a/plugin/src/domain/reconcile.ts +++ b/plugin/src/domain/reconcile.ts @@ -5,14 +5,14 @@ // `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. +import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire"; import type { Config, GameOverride } from "@rom-manager/contract"; +import type { ArtVerdict } from "../art/provider.js"; +import { killPrep } from "./close.js"; import { type DetectedEmulator, resolveEmulators } from "./emulators.js"; +import { type EffectiveLaunch, resolveLaunch } from "./launch.js"; import { type Platform, resolvePlatforms } from "./platforms.js"; import type { Os } from "./quote.js"; -import type { ArtVerdict } from "../art/provider.js"; -import type { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire"; -import { killPrep } from "./close.js"; -import { type EffectiveLaunch, resolveLaunch } from "./launch.js"; import type { RomCandidate } from "./scanner.js"; import { dedupeKey, diff --git a/plugin/src/index.ts b/plugin/src/index.ts new file mode 100644 index 0000000..c62e4de --- /dev/null +++ b/plugin/src/index.ts @@ -0,0 +1,48 @@ +// The plugin entry — the default export the runner discovers. Everything is Effect +// inside definePluginKit's async-main boundary (see @punktfunk/plugin-kit): migrate, +// start the sync engine, serve the console UI, park until interruption. +import { definePluginKit, serveUi } from "@punktfunk/plugin-kit"; +import { Effect } from "effect"; +import pkg from "../package.json" with { type: "json" }; +import { romLayer } from "./layer.js"; +import { migrateLegacyState } from "./migrate.js"; +import { makeApi } from "./services/api.js"; +import { RomCache } from "./services/cache.js"; +import { RomConfig } from "./services/config.js"; +import { RomSync } from "./services/engine.js"; + +export const PLUGIN_NAME = "rom-manager"; +export const UI_TITLE = "ROM Manager"; +export const UI_ICON = "gamepad-2"; + +const plugin = definePluginKit({ + name: PLUGIN_NAME, + version: pkg.version, + layer: romLayer, + main: Effect.gen(function* () { + yield* migrateLegacyState(PLUGIN_NAME); + const sync = yield* RomSync; + const config = yield* RomConfig; + const cache = yield* RomCache; + + // Headless sync first — the library reconciles even if the UI can't come up. + yield* sync.engine.start; + + yield* serveUi({ + title: UI_TITLE, + icon: UI_ICON, + staticDir: new URL("./ui/", import.meta.url), + api: makeApi({ sync, config, cache }), + }).pipe( + Effect.catch((e) => + Effect.logWarning( + `ui: could not serve the console surface (${e.cause}) — engine continues headless`, + ), + ), + ); + + yield* Effect.never; + }), +}); + +export default plugin; diff --git a/plugin/src/layer.ts b/plugin/src/layer.ts new file mode 100644 index 0000000..6f633e7 --- /dev/null +++ b/plugin/src/layer.ts @@ -0,0 +1,21 @@ +// The plugin's service graph over the kit base (HostClient | PluginInfo) — shared by the +// runner entry (index.ts), the CLI, and the dev server so they all run the same engine. + +import type { HostClient } from "@punktfunk/plugin-kit"; +import { type PluginInfo, ProviderClient } from "@punktfunk/plugin-kit"; +import { Layer } from "effect"; +import { RomCache } from "./services/cache.js"; +import { RomConfig } from "./services/config.js"; +import { RomSync } from "./services/engine.js"; + +export type RomServices = RomConfig | RomCache | RomSync | ProviderClient; + +export const romLayer: Layer.Layer< + RomServices, + never, + HostClient | PluginInfo +> = RomSync.layer.pipe( + Layer.provideMerge( + Layer.mergeAll(RomConfig.layer, RomCache.layer, ProviderClient.layer), + ), +); diff --git a/plugin/src/migrate.ts b/plugin/src/migrate.ts new file mode 100644 index 0000000..26669db --- /dev/null +++ b/plugin/src/migrate.ts @@ -0,0 +1,26 @@ +// One-shot legacy-state migration (pre-0.2.1 → plugin-state), OUT of the hot load path. +// Copy (not move): the old dir may be unwritable, and leaving it is harmless. Guarded — +// only fills files the new dir does not already have — and best-effort. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { pluginStateDir } from "@punktfunk/plugin-kit"; +import { Effect } from "effect"; + +export const migrateLegacyState = (name: string): Effect.Effect => + Effect.sync(() => { + const dir = pluginStateDir(name); + // /plugin-state// was the pre-0.2.1 home. + const legacy = path.join(path.dirname(path.dirname(dir)), name); + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + for (const file of ["config.json", "cache.json"]) { + const dst = path.join(dir, file); + const src = path.join(legacy, file); + if (!fs.existsSync(dst) && fs.existsSync(src)) { + fs.copyFileSync(src, dst); + } + } + } catch { + // best-effort — a failed copy just means the plugin starts from defaults + } + }); diff --git a/plugin/src/services/api.ts b/plugin/src/services/api.ts new file mode 100644 index 0000000..9faf197 --- /dev/null +++ b/plugin/src/services/api.ts @@ -0,0 +1,129 @@ +// The HttpApi implementation + SSE route, composed as the Layer `serveUi` mounts. +// Built from live service VALUES (not layers) so the handler runtime shares the plugin +// runtime's singletons (one engine, one config service). +import { + type CacheStore, + type ConfigService, + httpApiEnv, + sseRoute, +} from "@punktfunk/plugin-kit"; +import { + ConfigInvalid, + DetectFailed, + type EmulatorsPayload, + EVENTS_EVENT, + EVENTS_PATH, + type Platform, + type RomConfigSchema, + RomManagerApi, + resolveConfig, + ScanFailed, + SyncInProgress, +} from "@rom-manager/contract"; +import { Effect, Layer } from "effect"; +import type { HttpRouter } from "effect/unstable/http"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { resolvePlatforms } from "../domain/platforms.js"; +import type { CacheSchema } from "./cache.js"; +import type { RomSyncService } from "./engine.js"; + +export interface ApiDeps { + readonly sync: RomSyncService; + readonly config: ConfigService; + readonly cache: CacheStore; +} + +const emulatorsPayload = ( + deps: ApiDeps, + force: boolean, +): Effect.Effect => + Effect.gen(function* () { + const detected = yield* deps.sync.detect(force); + const at = (yield* deps.cache.get).detect?.at ?? null; + return { detected, detectedAt: at }; + }); + +export const makeApi = ( + deps: ApiDeps, +): Layer.Layer => { + const statusGroup = HttpApiBuilder.group(RomManagerApi, "status", (h) => + h.handle("get", () => deps.sync.status), + ); + + const configGroup = HttpApiBuilder.group(RomManagerApi, "config", (h) => + h + .handle("get", () => + deps.config.loadRaw.pipe( + Effect.map((raw) => ({ raw: raw as unknown })), + Effect.orDie, + ), + ) + .handle("put", ({ payload }) => + deps.config.saveRaw(payload).pipe( + Effect.mapError((e) => new ConfigInvalid({ issues: String(e) })), + // Loops restart + resync in the background; the PUT answers immediately. + Effect.tap(() => Effect.forkDetach(deps.sync.engine.reconfigure)), + Effect.map(() => ({ raw: payload as unknown })), + ), + ), + ); + + const libraryGroup = HttpApiBuilder.group(RomManagerApi, "library", (h) => + h.handle("preview", () => + deps.sync.preview.pipe( + Effect.mapError((e) => new ScanFailed({ message: String(e) })), + ), + ), + ); + + const catalogGroup = HttpApiBuilder.group(RomManagerApi, "catalog", (h) => + h + .handle("platforms", () => + deps.config.load.pipe( + Effect.orElseSucceed(() => resolveConfig({})), + Effect.map( + (config) => + [ + ...resolvePlatforms(config.platforms).values(), + ] as Array, + ), + ), + ) + .handle("emulators", () => emulatorsPayload(deps, false)) + .handle("detect", () => + emulatorsPayload(deps, true).pipe( + Effect.mapError((e) => new DetectFailed({ message: String(e) })), + ), + ), + ); + + const syncGroup = HttpApiBuilder.group(RomManagerApi, "sync", (h) => + h.handle("run", () => + deps.sync.engine.sync("manual").pipe( + Effect.orDie, // a SyncError is a 500; the 409 stays typed below + Effect.flatMap((outcome) => + outcome._tag === "AlreadyRunning" + ? Effect.fail(new SyncInProgress()) + : Effect.succeed(outcome.report), + ), + ), + ), + ); + + return Layer.mergeAll( + HttpApiBuilder.layer(RomManagerApi).pipe( + Layer.provide( + Layer.mergeAll( + statusGroup, + configGroup, + libraryGroup, + catalogGroup, + syncGroup, + ), + ), + Layer.provide(httpApiEnv), + ), + // EngineStatus is an identity codec (plain JSON shape) — default JSON encode. + sseRoute(EVENTS_PATH, deps.sync.statusChanges, { event: EVENTS_EVENT }), + ); +}; diff --git a/plugin/src/services/cache.ts b/plugin/src/services/cache.ts new file mode 100644 index 0000000..0bf0ae1 --- /dev/null +++ b/plugin/src/services/cache.ts @@ -0,0 +1,46 @@ +// The plugin's disposable derived state (cache.json), on the kit's CacheStore: art +// verdicts keyed by external_id, the last detection result, and the last-sync +// fingerprint. Corrupt/absent → empty; every mutation is write-through. +import { + type CacheStore, + makeCacheStore, + type PluginInfo, +} from "@punktfunk/plugin-kit"; +import { Artwork } from "@punktfunk/plugin-kit/wire"; +import { DetectedEmulator, LastSync, Os } from "@rom-manager/contract"; +import { Context, Effect, Layer, Schema } from "effect"; + +export const ArtVerdictSchema = Schema.Struct({ + art: Schema.NullOr(Artwork), + provider: Schema.String, + matcher: Schema.Number, +}); + +export const CacheSchema = Schema.Struct({ + art: Schema.Record(Schema.String, ArtVerdictSchema).pipe( + Schema.withDecodingDefaultKey(Effect.succeed({}), { + encodingStrategy: "passthrough", + }), + ), + detect: Schema.optionalKey( + Schema.Struct({ + at: Schema.Number, + os: Os, + emulators: Schema.Array(DetectedEmulator), + }), + ), + lastSync: Schema.optionalKey(LastSync), +}); +export type Cache = typeof CacheSchema.Type; + +export const emptyCache: Cache = { art: {} }; + +export class RomCache extends Context.Service< + RomCache, + CacheStore +>()("rom-manager/RomCache") { + static readonly layer: Layer.Layer = + Layer.effect(RomCache)( + makeCacheStore({ schema: CacheSchema, empty: emptyCache }), + ); +} diff --git a/plugin/src/services/config.ts b/plugin/src/services/config.ts new file mode 100644 index 0000000..0b45161 --- /dev/null +++ b/plugin/src/services/config.ts @@ -0,0 +1,17 @@ +// The plugin's config service — the kit's ConfigService over the contract schema. +// Raw round-trip by construction: the file on disk is always the authored shape. +import { + type ConfigService, + makeConfigService, + type PluginInfo, +} from "@punktfunk/plugin-kit"; +import { RomConfigSchema } from "@rom-manager/contract"; +import { Context, Layer } from "effect"; + +export class RomConfig extends Context.Service< + RomConfig, + ConfigService +>()("rom-manager/RomConfig") { + static readonly layer: Layer.Layer = + Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema })); +} diff --git a/plugin/src/services/engine.ts b/plugin/src/services/engine.ts new file mode 100644 index 0000000..7e06fa6 --- /dev/null +++ b/plugin/src/services/engine.ts @@ -0,0 +1,234 @@ +// RomSync — the plugin's engine service: wires the pure domain (scan → detect → warm → +// computeDesired) into the kit's generic SyncEngine (poll/watch/debounce/coalesce/ +// fingerprint) and the ProviderClient reconcile. Also owns the EngineStatus assembly the +// API and the SSE feed share, and the side-effect-light preview the Library page uses. + +import * as nodePath from "node:path"; +import { + type LastSync, + makeSyncEngine, + ProviderClient, + type SyncEngine, +} from "@punktfunk/plugin-kit"; +import type { ProviderEntry } from "@punktfunk/plugin-kit/wire"; +import { + type Config, + type DetectedEmulator, + type EngineStatus, + resolveConfig, + type SyncReport, +} from "@rom-manager/contract"; +import { Context, Duration, Effect, Layer, type Scope, Stream } from "effect"; +import { + type ArtVerdict, + selectArtProvider, + warmArt, +} from "../art/provider.js"; +import { + detectAll, + realDetectEnv, + resolveEmulators, +} from "../domain/emulators.js"; +import { resolvePlatforms } from "../domain/platforms.js"; +import { currentOs } from "../domain/quote.js"; +import { computeDesired, enumerateTitles } from "../domain/reconcile.js"; +import { type RomCandidate, realScanFs, scanRoot } from "../domain/scanner.js"; +import { RomCache } from "./cache.js"; +import { RomConfig } from "./config.js"; + +export const PROVIDER_ID = "rom-manager"; + +export interface RomSyncService { + readonly engine: SyncEngine; + /** Scan + cached detect + cached art → desired entries. NO warming, NO reconcile. */ + readonly preview: Effect.Effect< + { entries: ReadonlyArray; report: SyncReport }, + unknown + >; + /** Emulator detection (cached; `force` re-probes — the UI's Detect button). */ + readonly detect: ( + force: boolean, + ) => Effect.Effect>; + readonly status: Effect.Effect; + /** The SSE feed: a full EngineStatus on every engine transition. */ + readonly statusChanges: Stream.Stream; +} + +export class RomSync extends Context.Service()( + "rom-manager/RomSync", +) { + // Layer.effect runs `make` in the LAYER's scope (Scope is excluded from R) — the + // engine's watch/poll loops live exactly as long as the plugin runtime. + static readonly layer: Layer.Layer< + RomSync, + never, + RomConfig | RomCache | ProviderClient + > = Layer.effect(RomSync)(make()); +} + +function make(): Effect.Effect< + RomSyncService, + never, + RomConfig | RomCache | ProviderClient | Scope.Scope +> { + return Effect.gen(function* () { + const cfg = yield* RomConfig; + const cache = yield* RomCache; + const provider = yield* ProviderClient; + const os = currentOs(); + const detectEnv = realDetectEnv(); + + // Pure helpers collect log lines; callers flush them through Effect.log so they + // go through the runtime's journal-format logger (never console directly). + const flush = (lines: ReadonlyArray) => + Effect.forEach(lines, (l) => Effect.log(l), { discard: true }); + + const scanAll = (config: Config, lines: string[]): RomCandidate[] => { + const platforms = resolvePlatforms(config.platforms); + const out: RomCandidate[] = []; + for (const root of config.roots) { + const platform = platforms.get(root.platform); + if (!platform) { + lines.push( + `scan: root ${root.dir} has unknown platform "${root.platform}" — skipped`, + ); + continue; + } + try { + out.push( + ...scanRoot(realScanFs, root.dir, platform, [ + ...(root.excludes ?? []), + ]), + ); + } catch (e) { + lines.push(`scan: ${root.dir}: ${e}`); + } + } + return out; + }; + + const loadOrDefault = cfg.load.pipe( + Effect.orElseSucceed(() => resolveConfig({})), + ); + + const detect = (force: boolean) => + Effect.gen(function* () { + const config = yield* loadOrDefault; + const cached = (yield* cache.get).detect; + if (!force && cached && cached.os === os) { + return cached.emulators; + } + const defs = resolveEmulators(config.emulators); + const emulators = detectAll(detectEnv, defs.values()); + yield* cache + .update((c) => ({ + ...c, + detect: { at: Date.now(), os, emulators }, + })) + .pipe(Effect.ignore); + return emulators as ReadonlyArray; + }); + + /** Warm the art cache for everything the current scan will include (best-effort). */ + const warm = (config: Config, scan: RomCandidate[]) => + Effect.gen(function* () { + const lines: string[] = []; + const artProvider = selectArtProvider(config, (l) => lines.push(l)); + if (artProvider) { + const { survivors } = enumerateTitles(config, scan); + const targets = survivors.map((s) => ({ + externalId: s.externalId, + platform: s.platform, + parsed: s.parsed, + })); + // warmArt mutates a working copy; the store persists it atomically. + const working: { art: Record } = { + art: { ...(yield* cache.get).art }, + }; + const n = yield* Effect.promise(() => + warmArt(artProvider, targets, working, { + log: (l) => lines.push(l), + }), + ); + if (n > 0) { + yield* cache + .update((c) => ({ ...c, art: working.art })) + .pipe(Effect.ignore); + lines.push( + `art: resolved ${n} title(s) via ${artProvider.id.split(":")[0]}`, + ); + } + } + yield* flush(lines); + }); + + const computeWith = (withWarm: boolean) => + Effect.gen(function* () { + const config = yield* cfg.load; + const lines: string[] = []; + const scan = scanAll(config, lines); + yield* flush(lines); + const detected = yield* detect(false); + if (withWarm) yield* warm(config, scan); + const art = (yield* cache.get).art; + const result = computeDesired({ + config, + scan, + detected: [...detected], + art, + os, + }); + return { entries: result.entries, report: result.report }; + }); + + const engine = yield* makeSyncEngine< + SyncReport, + ReadonlyArray, + never + >({ + compute: () => computeWith(true), + apply: (entries) => provider.reconcile(PROVIDER_ID, entries), + lastSync: { + get: cache.get.pipe(Effect.map((c) => c.lastSync)), + set: (last: LastSync) => + cache.update((c) => ({ ...c, lastSync: last })).pipe(Effect.ignore), + }, + settings: loadOrDefault.pipe( + Effect.map((config) => ({ + pollInterval: Duration.minutes(Math.max(1, config.sync.pollMinutes)), + watch: config.sync.watch, + debounce: Duration.millis(config.sync.debounceMs), + watchDirs: config.roots.map((r) => r.dir), + })), + ), + }); + + const status: Effect.Effect = Effect.gen(function* () { + const config = yield* loadOrDefault; + const engineStatus = yield* engine.status; + const c = yield* cache.get; + return { + rootsConfigured: config.roots.length, + os, + artProvider: selectArtProvider(config)?.id ?? null, + syncing: engineStatus.syncing, + lastSync: engineStatus.lastSync, + lastReport: engineStatus.lastReport, + detectedAt: c.detect?.at, + paths: { + dir: nodePath.dirname(cfg.path), + config: cfg.path, + cache: cache.path, + }, + } satisfies EngineStatus; + }); + + return { + engine, + preview: computeWith(false), + detect, + status, + statusChanges: engine.changes.pipe(Stream.mapEffect(() => status)), + } satisfies RomSyncService; + }); +} diff --git a/plugin/test/libretro.test.ts b/plugin/test/libretro.test.ts index 2f928f8..94ccc3f 100644 --- a/plugin/test/libretro.test.ts +++ b/plugin/test/libretro.test.ts @@ -5,8 +5,8 @@ import { LibretroProvider, sanitizeName, } from "../src/art/libretro.js"; -import { parseTitle } from "../src/domain/titles.js"; import { platformById } from "../src/domain/platforms.js"; +import { parseTitle } from "../src/domain/titles.js"; const snes = platformById("snes")!; const nswitch = platformById("switch")!; diff --git a/plugin/test/quote.test.ts b/plugin/test/quote.test.ts index 8ac29c2..fb2ccff 100644 --- a/plugin/test/quote.test.ts +++ b/plugin/test/quote.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { quotePosix, quoteWindows, renderCommand } from "../src/domain/quote.js"; +import { + quotePosix, + quoteWindows, + renderCommand, +} from "../src/domain/quote.js"; describe("quotePosix", () => { test("wraps a plain path in single quotes", () => { diff --git a/plugin/test/reconcile.test.ts b/plugin/test/reconcile.test.ts index 26b21d4..d3194fa 100644 --- a/plugin/test/reconcile.test.ts +++ b/plugin/test/reconcile.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { resolveConfig } from "@rom-manager/contract"; +import type { ArtVerdict } from "../src/art/provider.js"; import type { DetectedEmulator } from "../src/domain/emulators.js"; import { computeDesired } from "../src/domain/reconcile.js"; import type { RomCandidate } from "../src/domain/scanner.js"; -import type { ArtVerdict } from "../src/art/provider.js"; const RA: DetectedEmulator = { id: "retroarch", diff --git a/plugin/test/scanner.test.ts b/plugin/test/scanner.test.ts index d5c3353..00db4c3 100644 --- a/plugin/test/scanner.test.ts +++ b/plugin/test/scanner.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { platformById } from "../src/domain/platforms.js"; import { type DirEnt, makeExcluder, type ScanFs, scanRoot, } from "../src/domain/scanner.js"; -import { platformById } from "../src/domain/platforms.js"; /** A synthetic filesystem from a flat `{ absPath: contents }` map (dirs inferred). */ const synthFs = (files: Record): ScanFs => { diff --git a/plugin/test/steamgriddb.test.ts b/plugin/test/steamgriddb.test.ts index 21cef99..cf1b7aa 100644 --- a/plugin/test/steamgriddb.test.ts +++ b/plugin/test/steamgriddb.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { SteamGridDbProvider } from "../src/art/steamgriddb.js"; -import { parseTitle } from "../src/domain/titles.js"; import { platformById } from "../src/domain/platforms.js"; +import { parseTitle } from "../src/domain/titles.js"; const snes = platformById("snes")!; diff --git a/plugin/tsconfig.json b/plugin/tsconfig.json index acc6b00..326d141 100644 --- a/plugin/tsconfig.json +++ b/plugin/tsconfig.json @@ -9,6 +9,7 @@ "verbatimModuleSyntax": true, "skipLibCheck": true, "noEmit": true, + "resolveJsonModule": true, "types": ["bun"] }, "include": ["src", "test"] diff --git a/plugin/types/index.d.ts b/plugin/types/index.d.ts new file mode 100644 index 0000000..74f70e1 --- /dev/null +++ b/plugin/types/index.d.ts @@ -0,0 +1,6 @@ +// The published type surface: a plugin is a runtime artifact — the only thing anyone +// imports from the package is the default PluginDef (the runner's discovery contract). +import type { PluginDef } from "@punktfunk/host"; + +declare const plugin: PluginDef; +export default plugin; diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 1ce1e64..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bun -// Dev/debug CLI (design §8) — reuses the engine one-shot. `scan`/`detect`/`preview` are offline (no -// host needed); `sync`/`uninstall` connect to the host and reconcile. Handy for configuring a headless -// box or checking what a `config.json` will produce before pushing it to the library. -// -// bunx punktfunk-plugin-rom-manager scan # what the scanner finds -// bunx punktfunk-plugin-rom-manager detect # which emulators are installed -// bunx punktfunk-plugin-rom-manager preview # the desired library (no host write) -// bunx punktfunk-plugin-rom-manager sync # reconcile into the live library -// bunx punktfunk-plugin-rom-manager uninstall # remove all this plugin's entries -// bunx punktfunk-plugin-rom-manager set-password # standalone-UI password - -import { connect } from "@punktfunk/host"; -import type { Config } from "./config.js"; -import { detectAll, realDetectEnv, resolveEmulators } from "./emulators.js"; -import { Engine } from "./engine/index.js"; -import { computeDesired, enumerateTitles } from "./engine/reconcile.js"; -import { type RomCandidate, realScanFs, scanRoot } from "./engine/scanner.js"; -import { resolvePlatforms } from "./platforms.js"; -import { currentOs } from "./quote.js"; -import { hashPassword } from "./server/password.js"; -import { loadCache, loadConfig, saveConfig } from "./state.js"; - -const scanAll = (config: Config): RomCandidate[] => { - const platforms = resolvePlatforms(config.platforms); - const out: RomCandidate[] = []; - for (const root of config.roots) { - const platform = platforms.get(root.platform); - if (!platform) { - console.error(`! root ${root.dir}: unknown platform "${root.platform}"`); - continue; - } - out.push(...scanRoot(realScanFs, root.dir, platform, root.excludes)); - } - return out; -}; - -const cmdScan = (): void => { - const config = loadConfig(); - if (config.roots.length === 0) { - console.log( - "No ROM roots configured. Add some to config.json or via the UI.", - ); - return; - } - const scan = scanAll(config); - const { survivors, skipped } = enumerateTitles(config, scan); - const perPlatform = new Map(); - for (const s of survivors) - perPlatform.set(s.platform.id, (perPlatform.get(s.platform.id) ?? 0) + 1); - console.log( - `Scanned ${config.roots.length} root(s): ${scan.length} candidate file(s), ${survivors.length} title(s).`, - ); - for (const [id, n] of [...perPlatform].sort()) console.log(` ${id}: ${n}`); - if (skipped.length) - console.log(` (${skipped.length} skipped by exclude/dedupe)`); -}; - -const cmdDetect = (): void => { - const config = loadConfig(); - const detected = detectAll( - realDetectEnv(), - resolveEmulators(config.emulators).values(), - ); - if (detected.length === 0) { - console.log("No emulators detected."); - return; - } - console.log(`Detected ${detected.length} emulator(s):`); - for (const d of detected) { - const cores = d.cores?.length ? ` — ${d.cores.length} core(s)` : ""; - console.log( - ` ${d.id} (${d.via})${d.contested ? " [contested]" : ""}${cores}`, - ); - } -}; - -const cmdPreview = (): void => { - const config = loadConfig(); - const scan = scanAll(config); - const detected = detectAll( - realDetectEnv(), - resolveEmulators(config.emulators).values(), - ); - const cache = loadCache(); - const { entries, report } = computeDesired({ - config, - scan, - detected, - art: cache.art, - os: currentOs(), - }); - console.log( - `Preview: ${entries.length} entr(y/ies) would be reconciled${report.overWarn ? " [scale warning]" : ""}.`, - ); - for (const e of entries.slice(0, 40)) - console.log(` ${e.external_id} → ${e.launch?.value ?? "(no launch)"}`); - if (entries.length > 40) console.log(` … and ${entries.length - 40} more`); - if (report.skipped.length) { - console.log(`\n${report.skipped.length} skipped:`); - for (const s of report.skipped.slice(0, 20)) - console.log(` ${s.title}: ${s.reason}`); - } - for (const w of report.warnings.slice(0, 10)) console.log(`! ${w}`); -}; - -const cmdSync = async (): Promise => { - const pf = await connect(); - try { - const engine = new Engine({ pf }); - const report = await engine.sync("cli"); - if (report) { - console.log( - `Synced: ${report.included} entr(y/ies), ${report.skipped.length} skipped.`, - ); - } else { - console.log("Sync did not complete (see log)."); - } - } finally { - pf.close(); - } -}; - -const cmdUninstall = async (): Promise => { - const pf = await connect(); - try { - await new Engine({ pf }).uninstall(); - console.log("Removed all rom-manager entries from the library."); - } finally { - pf.close(); - } -}; - -const cmdSetPassword = (password: string | undefined): void => { - if (!password) { - console.error("usage: set-password "); - process.exitCode = 2; - return; - } - const config = loadConfig(); - config.ui.passwordHash = hashPassword(password); - saveConfig(config); - console.log("Standalone-UI password set (config.ui.passwordHash)."); -}; - -const main = async (): Promise => { - const [cmd, arg] = process.argv.slice(2); - switch (cmd) { - case "scan": - return cmdScan(); - case "detect": - return cmdDetect(); - case "preview": - return cmdPreview(); - case "sync": - return cmdSync(); - case "uninstall": - return cmdUninstall(); - case "set-password": - return cmdSetPassword(arg); - default: - console.log( - "usage: punktfunk-plugin-rom-manager ", - ); - process.exitCode = cmd ? 2 : 0; - } -}; - -await main(); diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 88dbd38..0000000 --- a/src/config.ts +++ /dev/null @@ -1,151 +0,0 @@ -// The plugin's configuration model (design §9) — the single source of truth for the sync engine, -// editable by hand (headless boxes) or by the web UI. Everything the engine needs is derivable from -// this file plus a directory scan; the `resolveConfig` helper fills defaults so the rest of the code -// works against a fully-populated shape. - -import type { EmulatorDef } from "./emulators.js"; -import type { Platform } from "./platforms.js"; - -/** A ROM root: a directory paired with the platform its files belong to (design §5). */ -export interface RomRoot { - /** Absolute directory to scan. */ - dir: string; - /** Platform id (a key into the resolved platform registry). */ - platform: string; - /** Per-root glob ignore patterns (`*.sav`, `bios/**`). */ - excludes?: string[]; -} - -/** A per-platform launch override — replaces the platform's built-in default emulator/core. */ -export interface PlatformLaunch { - emulator: string; - core?: string; - extraArgs?: string; -} - -/** A per-game override, keyed by `external_id` (`snes/Chrono Trigger (USA).sfc`). */ -export interface GameOverride { - /** Skip this ROM entirely. */ - exclude?: boolean; - /** Override the resolved emulator/core/args for just this title. */ - emulator?: string; - core?: string; - extraArgs?: string; - /** Override the displayed title. */ - title?: string; - /** Override the box-art portrait URL. */ - art?: string; -} - -export interface SyncOptions { - /** Interval poll in minutes (watchers are unreliable on SMB/NFS — poll is the primary trigger). */ - pollMinutes: number; - /** Enable fs watching (an accelerator on top of the poll). */ - watch: boolean; - /** Debounce window for coalescing watch/poll-triggered rescans (ms). */ - debounceMs: number; - /** Platform ids to region-dedupe ("one entry per title", design §5) — default none. */ - dedupeRegions: string[]; - /** Region preference order for dedupe (best first). */ - regionPriority: string[]; - /** Warn in the UI above this entry count (design §8 scale guard). */ - warnEntries: number; - /** Hard cap — truncate with a loud report above this many entries. */ - maxEntries: number; - /** Attach `prep`/`undo` that kills the emulator at session end ("close emulator when stream ends"). */ - closeOnEnd: boolean; -} - -/** Which art source to use (design §7, revised): SteamGridDB (like Steam ROM Manager) or the keyless - * libretro-thumbnails fallback. `auto` = SteamGridDB when a key is set, else libretro. */ -export type ArtProviderChoice = "auto" | "steamgriddb" | "libretro"; - -export interface ArtOptions { - /** Fetch box art at all. */ - enabled: boolean; - /** Art source selection (default `auto`). */ - provider: ArtProviderChoice; - /** - * SteamGridDB API key (free, from steamgriddb.com profile preferences). Unlocks full portrait + - * hero + logo + header art with fuzzy title matching. Absent → the keyless libretro fallback. - */ - steamGridDbKey?: string; -} - -export interface UiOptions { - /** - * Fallback standalone mode (design §9): serve the UI on a fixed, possibly non-loopback port behind - * a password instead of via the console proxy. For host-only installs without the console. - */ - standalone: boolean; - /** Standalone bind port. */ - port: number; - /** Standalone bind address (defaults to loopback; a non-loopback bind REQUIRES a password). */ - bind: string; - /** scrypt password hash (`scrypt$$`), required for a non-loopback standalone bind. */ - passwordHash?: string; -} - -/** The on-disk config as authored (all optional — `resolveConfig` fills the rest). */ -export interface RawConfig { - roots?: RomRoot[]; - /** Per-platform launch overrides, keyed by platform id. */ - platformLaunch?: Record; - /** Per-game overrides, keyed by `external_id`. */ - gameOverrides?: Record; - /** Operator-added / overriding platform defs (merged over the built-ins by id). */ - platforms?: Platform[]; - /** Operator-added / overriding emulator defs (merged over the built-ins by id). */ - emulators?: EmulatorDef[]; - sync?: Partial; - art?: Partial; - ui?: Partial; - /** Dev/M0 acceptance flag: reconcile one hardcoded entry so the round-trip is observable. */ - devEntry?: boolean; -} - -/** The fully-resolved config the engine consumes. */ -export interface Config { - roots: RomRoot[]; - platformLaunch: Record; - gameOverrides: Record; - platforms?: Platform[]; - emulators?: EmulatorDef[]; - sync: SyncOptions; - art: ArtOptions; - ui: UiOptions; - devEntry: boolean; -} - -export const DEFAULT_SYNC: SyncOptions = { - pollMinutes: 15, - watch: true, - debounceMs: 2000, - dedupeRegions: [], - regionPriority: ["USA", "World", "Europe", "Japan"], - warnEntries: 2000, - maxEntries: 5000, - closeOnEnd: false, -}; - -export const DEFAULT_UI: UiOptions = { - standalone: false, - port: 47993, - bind: "127.0.0.1", -}; - -/** Fill defaults over an authored config so the rest of the engine sees a total shape. */ -export const resolveConfig = (raw: RawConfig): Config => ({ - roots: raw.roots ?? [], - platformLaunch: raw.platformLaunch ?? {}, - gameOverrides: raw.gameOverrides ?? {}, - platforms: raw.platforms, - emulators: raw.emulators, - sync: { ...DEFAULT_SYNC, ...raw.sync }, - art: { enabled: true, provider: "auto", ...raw.art }, - ui: { ...DEFAULT_UI, ...raw.ui }, - devEntry: raw.devEntry ?? false, -}); - -/** The empty starting config for a fresh install (no roots yet). */ -export const emptyConfig = (): Config => resolveConfig({}); diff --git a/src/const.ts b/src/const.ts deleted file mode 100644 index b06bb73..0000000 --- a/src/const.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Plugin identity (design §4). The package name is `punktfunk-plugin-rom-manager` (unscoped — the -// runner's glob), but the `definePlugin` name, the provider id, and the console-nav id are all the -// short `rom-manager`. -export const PLUGIN_NAME = "rom-manager"; -export const PROVIDER_ID = "rom-manager"; -export const UI_TITLE = "ROM Manager"; -export const UI_ICON = "gamepad-2"; diff --git a/src/engine/index.ts b/src/engine/index.ts deleted file mode 100644 index 9310803..0000000 --- a/src/engine/index.ts +++ /dev/null @@ -1,363 +0,0 @@ -// The sync engine orchestrator (design §3/§8): the headless half of the plugin. It ties the pure core -// (scanner → titles → reconcile) to the real world — filesystem scans, emulator detection, box-art -// warming, and the host reconcile PUT — and drives it on start, on an interval poll, on filesystem -// changes (debounced), and on demand from the UI/CLI. Fully functional from a hand-written -// `config.json` alone; the UI just edits the same config and pokes the same `sync()`. - -import { createHash } from "node:crypto"; -import * as fs from "node:fs"; -import type { Punktfunk } from "@punktfunk/host"; -import { selectArtProvider, warmArt } from "../art/provider.js"; -import type { Config } from "../config.js"; -import { - type DetectEnv, - type DetectedEmulator, - detectAll, - realDetectEnv, - resolveEmulators, -} from "../emulators.js"; -import { deleteProvider, reconcileProvider } from "../host.js"; -import { type Logger, makeLogger } from "../log.js"; -import { resolvePlatforms } from "../platforms.js"; -import { currentOs, type Os } from "../quote.js"; -import { - type Cache, - loadCache, - loadConfig, - saveCache, - statePaths, -} from "../state.js"; -import { - computeDesired, - type DesiredResult, - enumerateTitles, - type SyncReport, -} from "./reconcile.js"; -import { - type RomCandidate, - realScanFs, - type ScanFs, - scanRoot, -} from "./scanner.js"; - -export interface EngineDeps { - pf: Punktfunk; - os?: Os; - scanFs?: ScanFs; - detectEnv?: DetectEnv; - log?: Logger; -} - -export interface EngineStatus { - rootsConfigured: number; - os: Os; - artProvider: string | null; - lastSync?: Cache["lastSync"]; - lastReport?: SyncReport; - detected?: { at: number; emulators: DetectedEmulator[] }; - paths: ReturnType; - syncing: boolean; -} - -/** A stable content fingerprint of the desired entries — lets an unchanged rescan skip the PUT. */ -const fingerprint = (entries: unknown): string => - createHash("sha256").update(JSON.stringify(entries)).digest("hex"); - -/** A synthetic entry proving the reconcile round-trip end-to-end (M0 dev flag / acceptance). */ -const devEntry = (os: Os) => ({ - external_id: "__dev__/hello", - title: "ROM Manager (dev entry)", - launch: { - kind: "command" as const, - value: os === "windows" ? "cmd /c echo hi" : "echo hi", - }, -}); - -export class Engine { - private readonly pf: Punktfunk; - private readonly os: Os; - private readonly scanFs: ScanFs; - private readonly detectEnv: DetectEnv; - private readonly log: Logger; - private cache: Cache; - - private syncing = false; - private pending = false; - private lastReport?: SyncReport; - private pollTimer?: ReturnType; - private debounceTimer?: ReturnType; - private watchers: fs.FSWatcher[] = []; - private readonly listeners = new Set<(status: EngineStatus) => void>(); - - constructor(deps: EngineDeps) { - this.pf = deps.pf; - this.os = deps.os ?? currentOs(); - this.scanFs = deps.scanFs ?? realScanFs; - this.detectEnv = deps.detectEnv ?? realDetectEnv(); - this.log = deps.log ?? makeLogger(); - this.cache = loadCache(); - } - - // ---------------------------------------------------------------- lifecycle - - /** Initial sync, then wire the interval poll and (best-effort) filesystem watchers. */ - async start(): Promise { - await this.sync("startup"); - this.schedulePoll(); - this.setupWatchers(); - } - - /** Tear down timers and watchers (called from the plugin's shutdown finalizer). */ - stop(): void { - if (this.pollTimer) clearInterval(this.pollTimer); - if (this.debounceTimer) clearTimeout(this.debounceTimer); - for (const w of this.watchers) { - try { - w.close(); - } catch { - // already closed - } - } - this.watchers = []; - } - - private schedulePoll(): void { - const config = loadConfig(); - const ms = Math.max(1, config.sync.pollMinutes) * 60_000; - if (this.pollTimer) clearInterval(this.pollTimer); - this.pollTimer = setInterval(() => void this.sync("poll"), ms); - (this.pollTimer as { unref?: () => void }).unref?.(); - } - - private setupWatchers(): void { - for (const w of this.watchers) { - try { - w.close(); - } catch { - // ignore - } - } - this.watchers = []; - const config = loadConfig(); - if (!config.sync.watch) return; - for (const root of config.roots) { - try { - // `recursive` is honored on macOS/Windows; on Linux it throws and we watch the top dir - // only — the interval poll is the real safety net (watchers are unreliable on SMB/NFS). - const watcher = fs.watch(root.dir, { recursive: true }, () => - this.debouncedSync(), - ); - this.watchers.push(watcher); - } catch { - try { - this.watchers.push(fs.watch(root.dir, () => this.debouncedSync())); - } catch { - this.log(`watch: cannot watch ${root.dir} (poll only)`); - } - } - } - } - - private debouncedSync(): void { - const config = loadConfig(); - if (this.debounceTimer) clearTimeout(this.debounceTimer); - this.debounceTimer = setTimeout( - () => void this.sync("fs-change"), - config.sync.debounceMs, - ); - (this.debounceTimer as { unref?: () => void }).unref?.(); - } - - // ---------------------------------------------------------------- scan + detect - - private scanAll(config: Config): RomCandidate[] { - const platforms = resolvePlatforms(config.platforms); - const out: RomCandidate[] = []; - for (const root of config.roots) { - const platform = platforms.get(root.platform); - if (!platform) { - this.log( - `scan: root ${root.dir} has unknown platform "${root.platform}" — skipped`, - ); - continue; - } - try { - out.push(...scanRoot(this.scanFs, root.dir, platform, root.excludes)); - } catch (e) { - this.log(`scan: ${root.dir}: ${e}`); - } - } - return out; - } - - /** Emulator detection, cached until `force` (re-probed from the UI's "detect" button). */ - detect(config: Config, force: boolean): DetectedEmulator[] { - if ( - !force && - this.cache.detect && - this.cache.detect.os === this.os && - this.cache.detect.emulators.length >= 0 - ) { - return this.cache.detect.emulators; - } - const defs = resolveEmulators(config.emulators); - const emulators = detectAll(this.detectEnv, defs.values()); - this.cache.detect = { at: Date.now(), os: this.os, emulators }; - saveCache(this.cache); - return emulators; - } - - // ---------------------------------------------------------------- compute - - /** Scan + detect + (cached) art → desired entries, WITHOUT touching the host. Used by the UI preview. */ - async computeDry( - force = true, - ): Promise { - const config = loadConfig(); - const scan = this.scanAll(config); - const detected = this.detect(config, force); - await this.warm(config, scan); - const result = computeDesired({ - config, - scan, - detected, - art: this.cache.art, - os: this.os, - }); - return { ...result, detected }; - } - - /** Warm the art cache for everything the current scan will include (best-effort, provider-agnostic). */ - private async warm(config: Config, scan: RomCandidate[]): Promise { - const provider = selectArtProvider(config); - if (!provider) return; - const { survivors } = enumerateTitles(config, scan); - const targets = survivors.map((s) => ({ - externalId: s.externalId, - platform: s.platform, - parsed: s.parsed, - })); - const n = await warmArt(provider, targets, this.cache); - if (n > 0) { - saveCache(this.cache); - this.log(`art: resolved ${n} title(s) via ${provider.id.split(":")[0]}`); - } - } - - // ---------------------------------------------------------------- sync - - /** The guarded reconcile: coalesces concurrent triggers, skips the PUT when nothing changed. */ - async sync(reason: string): Promise { - if (this.syncing) { - this.pending = true; - return undefined; - } - this.syncing = true; - try { - const config = loadConfig(); - const scan = this.scanAll(config); - const detected = this.detect(config, false); - await this.warm(config, scan); - const result = computeDesired({ - config, - scan, - detected, - art: this.cache.art, - os: this.os, - }); - - const entries = config.devEntry - ? [...result.entries, devEntry(this.os)] - : result.entries; - const fp = fingerprint(entries); - if (this.cache.lastSync?.fingerprint !== fp) { - await reconcileProvider(this.pf, entries); - this.cache.lastSync = { - fingerprint: fp, - count: entries.length, - at: Date.now(), - }; - saveCache(this.cache); - this.log(`sync (${reason}): reconciled ${entries.length} title(s)`); - } else { - this.log(`sync (${reason}): no changes (${entries.length} title(s))`); - } - this.lastReport = result.report; - this.logReport(result.report); - return result.report; - } catch (e) { - this.log(`sync (${reason}) failed: ${e}`); - return undefined; - } finally { - this.syncing = false; - this.notify(); - if (this.pending) { - this.pending = false; - queueMicrotask(() => void this.sync("coalesced")); - } - } - } - - /** Subscribe to status changes (the UI's SSE feed). Returns an unsubscribe function. */ - subscribe(cb: (status: EngineStatus) => void): () => void { - this.listeners.add(cb); - return () => this.listeners.delete(cb); - } - - private notify(): void { - const status = this.status(); - for (const cb of this.listeners) { - try { - cb(status); - } catch { - // a dead SSE listener must not break sync - } - } - } - - private logReport(report: SyncReport): void { - if (report.skipped.length > 0) { - this.log( - ` ${report.skipped.length} skipped (e.g. ${report.skipped[0]?.title}: ${report.skipped[0]?.reason})`, - ); - } - if (report.overWarn) { - this.log( - ` WARNING: ${report.included} entries — large library (design scale guard)`, - ); - } - for (const w of report.warnings.slice(0, 5)) this.log(` ${w}`); - } - - /** Reconfigure timers/watchers after a config change (e.g. the UI saved new roots). */ - async reconfigure(): Promise { - this.schedulePoll(); - this.setupWatchers(); - await this.sync("config-change"); - } - - /** Remove every entry this provider owns (CLI `uninstall`). */ - async uninstall(): Promise { - await deleteProvider(this.pf); - this.cache.lastSync = undefined; - saveCache(this.cache); - this.log("uninstalled: provider entries removed"); - } - - // ---------------------------------------------------------------- status - - status(): EngineStatus { - const config = loadConfig(); - const provider = selectArtProvider(config); - return { - rootsConfigured: config.roots.length, - os: this.os, - artProvider: provider ? (provider.id.split(":")[0] ?? null) : null, - lastSync: this.cache.lastSync, - lastReport: this.lastReport, - detected: this.cache.detect, - paths: statePaths(), - syncing: this.syncing, - }; - } -} diff --git a/src/host.ts b/src/host.ts deleted file mode 100644 index f2c5cdd..0000000 --- a/src/host.ts +++ /dev/null @@ -1,20 +0,0 @@ -// The host side of the reconcile (design §2). We PUT the declarative entry list to the provider -// endpoint and let the host diff by `external_id` (stable ids, orphan drop, manual entries untouched). -// Done through `pf.request` rather than the typed `pf.api.*` deliberately: under the packaged runner -// the facade is built by the runner's *bundled* SDK copy, whose generated client may predate an -// endpoint — the untyped request seam is version-skew-proof (same reasoning as `servePluginUi`). - -import type { Punktfunk } from "@punktfunk/host"; -import { PROVIDER_ID } from "./const.js"; -import type { ProviderEntryInput } from "./wire.js"; - -/** Full-replace reconcile: the host makes the library match `entries` exactly for this provider. */ -export const reconcileProvider = ( - pf: Punktfunk, - entries: ProviderEntryInput[], -): Promise => - pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries); - -/** Clean uninstall: remove every entry this provider owns. */ -export const deleteProvider = (pf: Punktfunk): Promise => - pf.request("DELETE", `/library/provider/${PROVIDER_ID}`); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index f89eb67..0000000 --- a/src/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -// The plugin entry (design §11). `main` is the plain-async form (NOT the Effect form): the packaged -// runner bundles its own effect/SDK copy and cross-instance Effect identity is unverified (design D3), -// so the async facade sidesteps it entirely. The runner supervises this with restart-on-crash and a -// structured SIGTERM shutdown; a direct `bun src/index.ts` run works too (bottom of file). -// -// Lifecycle: connect (done by the runner/facade) → start the sync engine → serve the UI (console-hosted -// by default, standalone if configured) → run until SIGTERM → deregister the UI and stop the engine. -// Provider entries are deliberately LEFT in the library on shutdown so the games survive plugin -// restarts and host reboots; a clean uninstall is the explicit `uninstall` CLI command. - -import { connect, definePlugin, type Punktfunk } from "@punktfunk/host"; -import { PLUGIN_NAME } from "./const.js"; -import { Engine } from "./engine/index.js"; -import { log } from "./log.js"; -import { serveConsoleUi } from "./server/index.js"; -import { serveStandaloneUi } from "./server/standalone.js"; -import { loadConfig } from "./state.js"; -import { readVersion } from "./version.js"; - -const plugin = definePlugin({ - name: PLUGIN_NAME, - main: async (pf: Punktfunk) => { - const engine = new Engine({ pf }); - const config = loadConfig(); - - // Headless engine first — the library syncs even if the UI can't start. - await engine.start(); - - let closeUi: () => Promise = async () => {}; - try { - if (config.ui.standalone) { - const handle = serveStandaloneUi(engine, config); - closeUi = handle.close; - } else { - const handle = await serveConsoleUi(pf, engine, { - version: readVersion(), - }); - closeUi = handle.close; - log(`UI registered with the console (nav entry "${PLUGIN_NAME}")`); - } - } catch (e) { - log(`UI server failed to start (engine keeps running headless): ${e}`); - } - - // Run until the runner (or a direct SIGINT) asks us to stop. The runner sends SIGTERM on - // shutdown; both its handler and ours fire, and this resolves. - await new Promise((resolve) => { - process.once("SIGINT", resolve); - process.once("SIGTERM", resolve); - }); - - log("shutting down"); - await closeUi(); - engine.stop(); - }, -}); - -export default plugin; - -// Allow a direct `bun src/index.ts` run outside the managed runner (dev). -if (import.meta.main) { - const pf = await connect(); - await (plugin.main as (pf: Punktfunk) => Promise)(pf); - pf.close(); -} diff --git a/src/log.ts b/src/log.ts deleted file mode 100644 index b7c87b1..0000000 --- a/src/log.ts +++ /dev/null @@ -1,10 +0,0 @@ -// A tiny stamped logger, matching the runner's line format so a plugin's output reads consistently in -// the runner journal. `child` prefixes a scope (e.g. `[scan]`). -export type Logger = (line: string) => void; - -export const makeLogger = (prefix = "rom-manager"): Logger => { - return (line: string) => - console.log(`${new Date().toISOString()} [${prefix}] ${line}`); -}; - -export const log: Logger = makeLogger(); diff --git a/src/paths.ts b/src/paths.ts deleted file mode 100644 index ade451c..0000000 --- a/src/paths.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Where the plugin's own files live. The host config dir resolution mirrors the SDK's `configDir` -// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner -// use; the plugin owns a `rom-manager/` subtree under it (design §9), created 0700. -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** The host's config dir — the same resolution the host and SDK use. */ -export const hostConfigDir = (): string => { - const explicit = process.env.PUNKTFUNK_CONFIG_DIR; - if (explicit) return explicit; - if (process.platform === "win32") { - const base = process.env.ProgramData ?? process.env.APPDATA ?? "."; - return path.join(base, "punktfunk"); - } - const base = - process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - return path.join(base, "punktfunk"); -}; - -/** - * This plugin's private directory: `/plugin-state/rom-manager`. - * - * The state lives under `plugin-state/`, not straight under the config dir, because the managed - * runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked - * read-only there — `punktfunk-host plugins enable` grants the runner write on exactly - * `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like - * `hostConfigDir`, since we don't want to gate on an SDK version bump.) On Linux the runner owns - * the config dir, so the same path is writable with no special step. - */ -export const pluginDir = (): string => - path.join(hostConfigDir(), "plugin-state", "rom-manager"); - -/** The pre-0.2.1 location (`/rom-manager`), migrated away from on first run (state.ts). */ -export const legacyPluginDir = (): string => - path.join(hostConfigDir(), "rom-manager"); - -/** `/rom-manager/config.json` — operator-editable, atomic-written. */ -export const configPath = (): string => path.join(pluginDir(), "config.json"); - -/** `/rom-manager/cache.json` — disposable derived state (art verdicts, detection, fingerprint). */ -export const cachePath = (): string => path.join(pluginDir(), "cache.json"); - -/** - * Locate the built SPA directory (`dist/ui`) at runtime. The package ships as ONE self-contained bundle - * (`dist/index.js`) so it installs from the Gitea registry with no external deps — which means - * `import.meta.url` can be `dist/index.js` (bundled) or `dist/server/index.js` (unbundled dev). We walk - * up from the calling module looking for a `ui/index.html`, so both layouts resolve. `fromUrl` is the - * caller's `import.meta.url`. - */ -export const resolveUiDir = (fromUrl: string): string => { - let dir = path.dirname(fileURLToPath(fromUrl)); - for (let i = 0; i < 6; i++) { - for (const cand of [path.join(dir, "ui"), path.join(dir, "dist", "ui")]) { - try { - if (fs.existsSync(path.join(cand, "index.html"))) return cand; - } catch { - // keep walking - } - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - // Fallback to the bundled layout (dist/index.js → dist/ui) even if unbuilt. - return path.join(path.dirname(fileURLToPath(fromUrl)), "ui"); -}; diff --git a/src/server/index.ts b/src/server/index.ts deleted file mode 100644 index 0514934..0000000 --- a/src/server/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -// The console-hosted UI server (design §9, primary path). `servePluginUi` from the SDK owns the whole -// plugin side — a loopback ephemeral port behind a per-boot secret, registration + lease renewal with -// the host, and the console reverse-proxy contract — so we just hand it the built SPA directory and the -// plugin-local API router. The operator signs into the console once; the "ROM Manager" nav entry -// appears automatically. Zero human auth here. - -import { - type PluginUiHandle, - type Punktfunk, - servePluginUi, -} from "@punktfunk/host"; -import { PLUGIN_NAME, UI_ICON, UI_TITLE } from "../const.js"; -import type { Engine } from "../engine/index.js"; -import { resolveUiDir } from "../paths.js"; -import { makeRouter } from "./router.js"; - -/** Serve the UI through the console. The SPA dir resolves whether we run bundled or from source. */ -export const serveConsoleUi = ( - pf: Punktfunk, - engine: Engine, - opts?: { version?: string }, -): Promise => - servePluginUi(pf, { - id: PLUGIN_NAME, - title: UI_TITLE, - icon: UI_ICON, - version: opts?.version, - staticDir: resolveUiDir(import.meta.url), - fetch: makeRouter(engine), - }); diff --git a/src/server/password.ts b/src/server/password.ts deleted file mode 100644 index 0c41711..0000000 --- a/src/server/password.ts +++ /dev/null @@ -1,30 +0,0 @@ -// scrypt password hashing for the standalone fallback UI (design §9/§10.2). Format: -// `scrypt$$`. Verification is constant-time. Only used by the standalone -// server; the console-hosted path has no password of its own. - -import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; - -const KEYLEN = 32; - -/** Hash a password for storage in `config.ui.passwordHash`. */ -export const hashPassword = (password: string): string => { - const salt = randomBytes(16); - const hash = scryptSync(password, salt, KEYLEN); - return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`; -}; - -/** Constant-time verify a password against a stored `scrypt$...` hash. */ -export const verifyPassword = (password: string, stored: string): boolean => { - const parts = stored.split("$"); - if (parts.length !== 3 || parts[0] !== "scrypt" || !parts[1] || !parts[2]) - return false; - const salt = Buffer.from(parts[1], "base64url"); - const expected = Buffer.from(parts[2], "base64url"); - let actual: Buffer; - try { - actual = scryptSync(password, salt, expected.length); - } catch { - return false; - } - return actual.length === expected.length && timingSafeEqual(actual, expected); -}; diff --git a/src/server/router.ts b/src/server/router.ts deleted file mode 100644 index 90f4cfa..0000000 --- a/src/server/router.ts +++ /dev/null @@ -1,108 +0,0 @@ -// The plugin-local REST/SSE API (design §9) — a pure `(Request) => Response | undefined` the UI talks -// to. Paths arrive prefix-stripped (the console proxy already removed `/plugin-ui/rom-manager`), so we -// match `/api/...` directly; a non-`/api` path returns `undefined` to fall through to the static SPA. -// The same router backs both the console-proxied server and the standalone fallback. - -import { type RawConfig, resolveConfig } from "../config.js"; -import { resolveEmulators } from "../emulators.js"; -import type { Engine, EngineStatus } from "../engine/index.js"; -import { resolvePlatforms } from "../platforms.js"; -import { loadConfig, saveConfig } from "../state.js"; - -const json = (data: unknown, status = 200): Response => - Response.json(data, { status }); - -/** A Server-Sent-Events stream that pushes the engine status on every change (design §9 progress feed). */ -const sse = (engine: Engine): Response => { - const enc = new TextEncoder(); - let unsubscribe = () => {}; - let ping: ReturnType | undefined; - const stream = new ReadableStream({ - start(controller) { - const send = (status: EngineStatus) => { - try { - controller.enqueue( - enc.encode(`event: status\ndata: ${JSON.stringify(status)}\n\n`), - ); - } catch { - // stream already closed - } - }; - send(engine.status()); - unsubscribe = engine.subscribe(send); - // Keep the connection warm through any buffering proxy. - ping = setInterval(() => { - try { - controller.enqueue(enc.encode(": ping\n\n")); - } catch { - // ignore - } - }, 15_000); - (ping as { unref?: () => void }).unref?.(); - }, - cancel() { - unsubscribe(); - if (ping) clearInterval(ping); - }, - }); - return new Response(stream, { - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }, - }); -}; - -/** Build the plugin-local API router bound to an engine. */ -export const makeRouter = - (engine: Engine) => - async (req: Request): Promise => { - const { pathname } = new URL(req.url); - const method = req.method; - if (!pathname.startsWith("/api/")) return undefined; // static SPA handles it - - try { - if (pathname === "/api/status" && method === "GET") { - return json(engine.status()); - } - if (pathname === "/api/config" && method === "GET") { - return json(loadConfig()); - } - if (pathname === "/api/config" && method === "PUT") { - const body = (await req.json()) as RawConfig; - const resolved = resolveConfig(body); - saveConfig(resolved); - await engine.reconfigure(); - return json(resolved); - } - if (pathname === "/api/preview" && method === "GET") { - return json(await engine.computeDry(false)); - } - if (pathname === "/api/detect" && method === "POST") { - return json(engine.detect(loadConfig(), true)); - } - if (pathname === "/api/sync" && method === "POST") { - const report = await engine.sync("ui"); - return report - ? json(report) - : json({ error: "a sync is already running" }, 409); - } - if (pathname === "/api/platforms" && method === "GET") { - return json([...resolvePlatforms(loadConfig().platforms).values()]); - } - if (pathname === "/api/emulators" && method === "GET") { - const config = loadConfig(); - return json({ - defs: [...resolveEmulators(config.emulators).values()], - detected: engine.detect(config, false), - }); - } - if (pathname === "/api/events" && method === "GET") { - return sse(engine); - } - return json({ error: "not found" }, 404); - } catch (e) { - return json({ error: String(e) }, 500); - } - }; diff --git a/src/server/standalone.ts b/src/server/standalone.ts deleted file mode 100644 index e10eaa6..0000000 --- a/src/server/standalone.ts +++ /dev/null @@ -1,151 +0,0 @@ -// The standalone fallback UI server (design §9/§10.2, D1): for host-only installs without the console, -// or as schedule insurance if the console surface is unavailable. Serves the SAME SPA + router, but on -// a fixed port with its own password/session auth instead of the console proxy. Fail-closed: a -// non-loopback bind REQUIRES a password (a UI that can rewrite launch templates is host-user code). - -import * as nodePath from "node:path"; -import type { Config } from "../config.js"; -import type { Engine } from "../engine/index.js"; -import { log } from "../log.js"; -import { resolveUiDir } from "../paths.js"; -import { verifyPassword } from "./password.js"; -import { makeRouter } from "./router.js"; - -export interface StandaloneHandle { - url: string; - close(): Promise; -} - -const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); -const COOKIE = "pf_rm_session"; - -const loginPage = (error?: string): Response => - new Response( - ` -ROM Manager - -

ROM Manager

${error ? `
${error}
` : ""} -
`, - { - status: error ? 401 : 200, - headers: { "content-type": "text/html; charset=utf-8" }, - }, - ); - -/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */ -const staticFile = (root: string, pathname: string): string | null => { - let rel: string; - try { - rel = decodeURIComponent(pathname); - } catch { - return null; - } - if (rel.endsWith("/")) rel += "index.html"; - if (!rel.startsWith("/")) rel = `/${rel}`; - const abs = nodePath.resolve(root, `.${rel}`); - const rootAbs = nodePath.resolve(root); - if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null; - return abs; -}; - -const cookieHas = (req: Request, name: string, value: string): boolean => { - const raw = req.headers.get("cookie") ?? ""; - return raw.split(/;\s*/).some((c) => c === `${name}=${value}`); -}; - -/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */ -export const serveStandaloneUi = ( - engine: Engine, - config: Config, -): StandaloneHandle => { - const { port, bind, passwordHash } = config.ui; - const isLoopback = LOOPBACK.has(bind); - if (!isLoopback && !passwordHash) { - throw new Error( - `standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-rom-manager set-password\`, or bind 127.0.0.1)`, - ); - } - const authRequired = Boolean(passwordHash); - // A per-boot session token — any client presenting it in the cookie is authed until the plugin restarts. - const sessionToken = crypto.randomUUID().replace(/-/g, ""); - const root = resolveUiDir(import.meta.url); - const router = makeRouter(engine); - - const authed = (req: Request) => - !authRequired || cookieHas(req, COOKIE, sessionToken); - - const server = Bun.serve({ - hostname: bind, - port, - async fetch(req) { - const { pathname } = new URL(req.url); - if (pathname === "/__health") return Response.json({ ok: true }); - - if (authRequired && pathname === "/login" && req.method === "POST") { - const form = await req.formData().catch(() => null); - const password = form?.get("password"); - if ( - typeof password === "string" && - passwordHash && - verifyPassword(password, passwordHash) - ) { - return new Response(null, { - status: 303, - headers: { - location: "./", - "set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`, - }, - }); - } - return loginPage("Incorrect password"); - } - if (pathname === "/logout" && req.method === "POST") { - return new Response(null, { - status: 303, - headers: { - location: "./", - "set-cookie": `${COOKIE}=; Max-Age=0; Path=/`, - }, - }); - } - - if (!authed(req)) { - if (pathname.startsWith("/api/")) - return Response.json({ error: "unauthorized" }, { status: 401 }); - return loginPage(); - } - - // 1) plugin-local API - const api = await router(req); - if (api) return api; - // 2) static asset - const file = staticFile(root, pathname); - if (file) { - const bf = Bun.file(file); - if (await bf.exists()) return new Response(bf); - } - // 3) SPA fallback - if ( - req.method === "GET" && - (req.headers.get("accept") ?? "").includes("text/html") - ) { - const index = Bun.file(nodePath.join(root, "index.html")); - if (await index.exists()) return new Response(index); - } - return new Response("not found", { status: 404 }); - }, - }); - - const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`; - log( - `standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`, - ); - return { - url, - async close() { - server.stop(true); - }, - }; -}; diff --git a/src/state.ts b/src/state.ts deleted file mode 100644 index ea9075c..0000000 --- a/src/state.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Config/cache persistence (design §9): the plugin owns two files under `/rom-manager/`, -// created 0700. `config.json` is operator-editable and atomically rewritten (temp + rename); the -// plugin refuses a group/world-writable `config.json` (design §10.3 - the same sshd rule the runner -// and hooks enforce, since the UI can rewrite launch templates => config = host-user code). `cache.json` -// is disposable derived state. - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { type Config, type RawConfig, resolveConfig } from "./config.js"; -import type { DetectedEmulator } from "./emulators.js"; -import { cachePath, configPath, legacyPluginDir, pluginDir } from "./paths.js"; -import type { Artwork } from "./wire.js"; - -/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with the provider - * and its matcher version so a provider switch or an algorithm bump re-resolves (design §7). */ -export interface ArtVerdict { - art: Artwork | null; - /** The provider that produced this verdict (`steamgriddb` | `libretro`). */ - provider: string; - /** The provider's matcher version at resolve time. */ - matcher: number; -} - -export interface Cache { - /** Art verdicts keyed by `external_id` (provider-agnostic, stable across rescans). */ - art: Record; - /** Last emulator detection result (re-probed on demand). */ - detect?: { at: number; os: string; emulators: DetectedEmulator[] }; - /** Fingerprint + count of the last reconcile - lets a rescan skip an unchanged PUT (design §8). */ - lastSync?: { fingerprint: string; count: number; at: number }; -} - -export const emptyCache = (): Cache => ({ art: {} }); - -/** Create the plugin dir 0700 if absent (idempotent), migrating pre-0.2.1 state on first run. */ -const ensureDir = (): void => { - fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 }); - migrateLegacyState(); -}; - -/** - * One-time move from the pre-0.2.1 location (`/rom-manager`) to the runner-writable - * `/plugin-state/rom-manager`. The old path sat directly under the config dir, which - * the de-privileged Windows runner (LocalService) can read but not write. Copy (not move): the old - * dir may be unwritable, and leaving it is harmless. Guarded — only fills a file the new dir does - * not already have — and best-effort: a failed copy just means the plugin starts from defaults. - */ -const migrateLegacyState = (): void => { - const legacy = legacyPluginDir(); - for (const name of ["config.json", "cache.json"]) { - const dst = path.join(pluginDir(), name); - const src = path.join(legacy, name); - try { - if (!fs.existsSync(dst) && fs.existsSync(src)) fs.copyFileSync(src, dst); - } catch { - // best-effort - } - } -}; - -/** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */ -const assertNotWorldWritable = (file: string): void => { - if (process.platform === "win32") return; - let mode: number; - try { - mode = fs.statSync(file).mode; - } catch { - return; // absent - nothing to guard - } - if ((mode & 0o022) !== 0) { - throw new Error( - `refusing ${file}: it is group/world-writable (chmod go-w it first) - this file controls commands run as the host user`, - ); - } -}; - -/** Atomic write: temp file in the same dir, then rename. */ -const atomicWrite = (file: string, data: string): void => { - ensureDir(); - const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tmp, data, { mode: 0o600 }); - fs.renameSync(tmp, file); -}; - -/** Load the authored config (defaults filled). A missing file yields the empty config. */ -export const loadConfig = (): Config => { - // Migrate the pre-0.2.1 location before the first read, or an existing operator config would - // look absent and silently reset to defaults. Best-effort — never block a load. - try { - ensureDir(); - } catch { - // ignore — fall through to the read below - } - const file = configPath(); - let raw = ""; - try { - raw = fs.readFileSync(file, "utf8"); - } catch { - return resolveConfig({}); - } - assertNotWorldWritable(file); - const parsed = JSON.parse(raw) as RawConfig; - return resolveConfig(parsed); -}; - -/** Persist a config (atomic). The engine round-trips the *resolved* shape; defaults are re-elided on load. */ -export const saveConfig = (config: Config): void => { - atomicWrite(configPath(), `${JSON.stringify(config, null, 2)}\n`); -}; - -/** Save the raw (operator-authored) config verbatim - the shape the UI edits. */ -export const saveRawConfig = (raw: RawConfig): void => { - atomicWrite(configPath(), `${JSON.stringify(raw, null, 2)}\n`); -}; - -export const loadCache = (): Cache => { - try { - ensureDir(); // migrate pre-0.2.1 cache before the read (best-effort) - } catch { - // ignore - } - try { - const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache; - return { ...parsed, art: parsed.art ?? {} }; - } catch { - return emptyCache(); - } -}; - -export const saveCache = (cache: Cache): void => { - atomicWrite(cachePath(), JSON.stringify(cache)); -}; - -/** Absolute paths, exported for the UI/CLI status view. */ -export const statePaths = () => ({ - dir: pluginDir(), - config: configPath(), - cache: cachePath(), - relConfig: path.basename(configPath()), -}); diff --git a/src/version.ts b/src/version.ts deleted file mode 100644 index f29e33f..0000000 --- a/src/version.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Best-effort read of the plugin's own version from package.json (shown in the console page header). -// `../package.json` resolves to the repo root whether this module runs from `src/` or `dist/`. -import * as fs from "node:fs"; -import { fileURLToPath } from "node:url"; - -export const readVersion = (): string | undefined => { - try { - const file = fileURLToPath(new URL("../package.json", import.meta.url)); - const pkg = JSON.parse(fs.readFileSync(file, "utf8")) as { - version?: string; - }; - return pkg.version; - } catch { - return undefined; - } -}; diff --git a/src/wire.ts b/src/wire.ts deleted file mode 100644 index f390808..0000000 --- a/src/wire.ts +++ /dev/null @@ -1,33 +0,0 @@ -// The host provider-reconcile wire shapes (mirrors `@punktfunk/host`'s generated `ProviderEntryInput` -// et al. — the facade doesn't re-export them, and we PUT the raw array via `pf.request`, so we own the -// shape here). Kept minimal and stable; the host validates it. - -/** Cover art — all URLs. The console/clients prefer `portrait` for a grid (design §7). */ -export interface Artwork { - portrait?: string | null; - hero?: string | null; - logo?: string | null; - header?: string | null; -} - -/** How the host launches a title. For this plugin always `{ kind: "command", value: }`. */ -export interface LaunchSpec { - kind: "command"; - value: string; -} - -/** A per-title prep/undo step (design §6): `do` runs before launch, `undo` at session end (reverse order). */ -export interface PrepCmd { - do: string; - undo?: string | null; -} - -/** One title in the declarative reconcile payload (`PUT /api/v1/library/provider/rom-manager`). */ -export interface ProviderEntryInput { - /** The provider's stable id for this title — the reconcile diff key (design §4: `/`). */ - external_id: string; - title: string; - art?: Artwork; - launch?: LaunchSpec | null; - prep?: PrepCmd[]; -} diff --git a/test/state.test.ts b/test/state.test.ts deleted file mode 100644 index a8ce0d3..0000000 --- a/test/state.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// State persistence: the plugin writes under `/plugin-state/rom-manager`, and on first -// run migrates an operator config authored in the pre-0.2.1 `/rom-manager` location — -// the write path moved when the runner was de-privileged, and a missed migration would silently -// reset the operator's settings. -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { loadConfig, saveRawConfig } from "../src/state.js"; - -let root: string; -let saved: string | undefined; - -beforeEach(() => { - saved = process.env.PUNKTFUNK_CONFIG_DIR; - root = fs.mkdtempSync(path.join(os.tmpdir(), "rm-state-")); - process.env.PUNKTFUNK_CONFIG_DIR = root; -}); -afterEach(() => { - if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; - else process.env.PUNKTFUNK_CONFIG_DIR = saved; - fs.rmSync(root, { recursive: true, force: true }); -}); - -describe("state location + migration", () => { - test("persists under plugin-state/rom-manager", () => { - saveRawConfig({ roots: [{ dir: "/games", platform: "snes" }] }); - expect( - fs.existsSync( - path.join(root, "plugin-state", "rom-manager", "config.json"), - ), - ).toBe(true); - }); - - test("migrates a pre-0.2.1 config from /rom-manager on first load", () => { - // An operator config authored in the OLD location. - const legacy = path.join(root, "rom-manager"); - fs.mkdirSync(legacy, { recursive: true }); - fs.writeFileSync( - path.join(legacy, "config.json"), - JSON.stringify({ roots: [{ dir: "/legacy/games", platform: "snes" }] }), - ); - // First load migrates it — the operator's roots survive rather than resetting to defaults. - const cfg = loadConfig(); - expect(cfg.roots).toEqual([{ dir: "/legacy/games", platform: "snes" }]); - expect( - fs.existsSync( - path.join(root, "plugin-state", "rom-manager", "config.json"), - ), - ).toBe(true); - }); - - test("does not clobber an existing new-location config with the legacy one", () => { - const legacy = path.join(root, "rom-manager"); - fs.mkdirSync(legacy, { recursive: true }); - fs.writeFileSync( - path.join(legacy, "config.json"), - JSON.stringify({ roots: [{ dir: "/old", platform: "snes" }] }), - ); - saveRawConfig({ roots: [{ dir: "/new", platform: "snes" }] }); // new location already authored - expect(loadConfig().roots).toEqual([{ dir: "/new", platform: "snes" }]); - }); -}); diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index 92c6a62..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "moduleResolution": "bundler" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "test", "ui", "dist", "node_modules"] -}