feat: Effect services + HttpApi implementation + kit entry/CLI/dev server

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:04:25 +02:00
parent d6cf5f3d36
commit 899028e20f
43 changed files with 811 additions and 1558 deletions
+18 -8
View File
@@ -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. # 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 # One root install covers all workspaces (contract + plugin + ui); @punktfunk/* and
# bunfig scope maps; `effect` comes from npm. Publish runs on a `v*` tag. # @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 name: CI
on: on:
@@ -28,20 +29,27 @@ jobs:
run: | run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } 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" printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install - name: Install (workspace)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Lint & format - name: Lint & format
run: bunx biome check run: bunx biome check
- name: Typecheck (backend) - name: Typecheck (contract)
working-directory: contract
run: bunx tsc --noEmit 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 run: bun test
- name: Build backend + SPA - name: Build backend bundle + SPA
working-directory: plugin
run: bun run build:all run: bun run build:all
- name: Typecheck (UI) - name: Typecheck (UI)
working-directory: ui working-directory: ui
run: bunx tsc --noEmit run: bunx tsc --noEmit
- name: Sanity — plugin default export is a valid PluginDef - name: Sanity — plugin default export is a valid PluginDef
working-directory: plugin
run: | 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)' 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: | run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } 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" printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install - name: Install (workspace)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Tag matches package version - name: Tag matches package version
working-directory: plugin
run: | run: |
TAG="${GITHUB_REF_NAME#v}" TAG="${GITHUB_REF_NAME#v}"
PKG="$(node -p "require('./package.json').version")" PKG="$(node -p "require('./package.json').version")"
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; } test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; }
- name: Publish (prepublishOnly builds backend + SPA) - name: Publish (prepublishOnly builds backend + SPA)
working-directory: plugin
run: bun publish run: bun publish
-38
View File
@@ -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"
}
}
+24 -8
View File
@@ -1,5 +1,7 @@
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi // The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere. // 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 { Schema } from "effect";
import { import {
HttpApi, HttpApi,
@@ -7,9 +9,18 @@ import {
HttpApiGroup, HttpApiGroup,
HttpApiSchema, HttpApiSchema,
} from "effect/unstable/httpapi"; } from "effect/unstable/httpapi";
import { ProviderEntry } from "@punktfunk/plugin-kit/wire"; import {
import { DetectedEmulator, EngineStatus, Platform, SyncReport } from "./domain.js"; DetectedEmulator,
import { ConfigInvalid, DetectFailed, ScanFailed, SyncInProgress } from "./errors.js"; 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. /** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
* The UI resolves it locally by decoding through the SHARED RomConfigSchema. */ * The UI resolves it locally by decoding through the SHARED RomConfigSchema. */
@@ -29,11 +40,13 @@ export const EmulatorsPayload = Schema.Struct({
}); });
export type EmulatorsPayload = typeof EmulatorsPayload.Type; export type EmulatorsPayload = typeof EmulatorsPayload.Type;
export const RomManagerApi = HttpApi.make("rom-manager").add( export const RomManagerApi = HttpApi.make("rom-manager")
.add(
HttpApiGroup.make("status").add( HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }), HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
), ),
).add( )
.add(
HttpApiGroup.make("config") HttpApiGroup.make("config")
.add( .add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }), HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
@@ -45,14 +58,16 @@ export const RomManagerApi = HttpApi.make("rom-manager").add(
error: ConfigInvalid.pipe(HttpApiSchema.status(400)), error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
}), }),
), ),
).add( )
.add(
HttpApiGroup.make("library").add( HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", { HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult, success: PreviewResult,
error: ScanFailed.pipe(HttpApiSchema.status(500)), error: ScanFailed.pipe(HttpApiSchema.status(500)),
}), }),
), ),
).add( )
.add(
HttpApiGroup.make("catalog") HttpApiGroup.make("catalog")
.add( .add(
HttpApiEndpoint.get("platforms", "/api/platforms", { HttpApiEndpoint.get("platforms", "/api/platforms", {
@@ -70,7 +85,8 @@ export const RomManagerApi = HttpApi.make("rom-manager").add(
error: DetectFailed.pipe(HttpApiSchema.status(500)), error: DetectFailed.pipe(HttpApiSchema.status(500)),
}), }),
), ),
).add( )
.add(
HttpApiGroup.make("sync").add( HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", { HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport, success: SyncReport,
+1 -4
View File
@@ -9,10 +9,7 @@
import { Effect, Schema } from "effect"; import { Effect, Schema } from "effect";
import { EmulatorDef, Platform } from "./domain.js"; import { EmulatorDef, Platform } from "./domain.js";
const withDefault = <S extends Schema.Top>( const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema: S,
value: S["Encoded"],
) =>
schema.pipe( schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), { Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit", encodingStrategy: "omit",
+10 -9
View File
@@ -2,7 +2,8 @@
// the derived types keep the original domain names so the pure core reads unchanged. // the derived types keep the original domain names so the pure core reads unchanged.
import { Schema } from "effect"; 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; export type Os = typeof Os.Type;
/** Default emulator + core for a platform (operator-overridable per platform / per game). */ /** Default emulator + core for a platform (operator-overridable per platform / per game). */
@@ -50,13 +51,13 @@ export const DetectedEmulator = Schema.Struct({
name: Schema.String, name: Schema.String,
template: Schema.String, template: Schema.String,
supportsArchives: Schema.Boolean, supportsArchives: Schema.Boolean,
contested: Schema.optionalKey(Schema.Boolean), contested: Schema.optional(Schema.Boolean),
exeToken: Schema.String, exeToken: Schema.String,
exePath: Schema.optionalKey(Schema.String), exePath: Schema.optional(Schema.String),
appId: Schema.optionalKey(Schema.String), appId: Schema.optional(Schema.String),
via: Schema.Literals(["path", "file", "flatpak"]), via: Schema.Literals(["path", "file", "flatpak"]),
coresDir: Schema.optionalKey(Schema.String), coresDir: Schema.optional(Schema.String),
cores: Schema.optionalKey(Schema.Array(Schema.String)), cores: Schema.optional(Schema.Array(Schema.String)),
}); });
export type DetectedEmulator = typeof DetectedEmulator.Type; export type DetectedEmulator = typeof DetectedEmulator.Type;
@@ -96,9 +97,9 @@ export const EngineStatus = Schema.Struct({
os: Os, os: Os,
artProvider: Schema.NullOr(Schema.String), artProvider: Schema.NullOr(Schema.String),
syncing: Schema.Boolean, syncing: Schema.Boolean,
lastSync: Schema.optionalKey(LastSync), lastSync: Schema.optional(LastSync),
lastReport: Schema.optionalKey(SyncReport), lastReport: Schema.optional(SyncReport),
detectedAt: Schema.optionalKey(Schema.Number), detectedAt: Schema.optional(Schema.Number),
paths: Schema.Struct({ paths: Schema.Struct({
dir: Schema.String, dir: Schema.String,
config: Schema.String, config: Schema.String,
+4 -1
View File
@@ -24,7 +24,10 @@
"bin": { "bin": {
"punktfunk-plugin-rom-manager": "./dist/cli.js" "punktfunk-plugin-rom-manager": "./dist/cli.js"
}, },
"files": ["dist", "types"], "files": [
"dist",
"types"
],
"publishConfig": { "publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/" "registry": "https://git.unom.io/api/packages/unom/npm/"
}, },
+2 -2
View File
@@ -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 "../domain/titles.js";
import type { Platform } from "../domain/platforms.js";
import type { Artwork } from "@punktfunk/plugin-kit/wire"; 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"; import type { ArtProvider } from "./provider.js";
const BASE = "https://thumbnails.libretro.com"; const BASE = "https://thumbnails.libretro.com";
+2 -2
View File
@@ -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 "../domain/titles.js"; import type { Artwork } from "@punktfunk/plugin-kit/wire";
import type { Platform } from "../domain/platforms.js"; 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"; import type { ArtProvider } from "./provider.js";
const BASE = "https://www.steamgriddb.com/api/v2"; const BASE = "https://www.steamgriddb.com/api/v2";
+97
View File
@@ -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<string, CliCommand<RomServices>> = {
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,
});
+71
View File
@@ -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));
});
+1 -1
View File
@@ -5,9 +5,9 @@
// stretch item. // stretch item.
import * as nodePath from "node:path"; import * as nodePath from "node:path";
import type { PrepStep } from "@punktfunk/plugin-kit/wire";
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 { 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");
+4 -4
View File
@@ -5,14 +5,14 @@
// `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 { Artwork, ProviderEntry } from "@punktfunk/plugin-kit/wire";
import type { Config, GameOverride } from "@rom-manager/contract"; 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 DetectedEmulator, resolveEmulators } from "./emulators.js";
import { type EffectiveLaunch, resolveLaunch } from "./launch.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 "../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 type { RomCandidate } from "./scanner.js";
import { import {
dedupeKey, dedupeKey,
+48
View File
@@ -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;
+21
View File
@@ -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),
),
);
+26
View File
@@ -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<void> =>
Effect.sync(() => {
const dir = pluginStateDir(name);
// <config_dir>/plugin-state/<name> → <config_dir>/<name> 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
}
});
+129
View File
@@ -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<typeof RomConfigSchema>;
readonly cache: CacheStore<typeof CacheSchema>;
}
const emulatorsPayload = (
deps: ApiDeps,
force: boolean,
): Effect.Effect<EmulatorsPayload> =>
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<never, never, HttpRouter.HttpRouter> => {
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<Platform>,
),
),
)
.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 }),
);
};
+46
View File
@@ -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<typeof CacheSchema>
>()("rom-manager/RomCache") {
static readonly layer: Layer.Layer<RomCache, never, PluginInfo> =
Layer.effect(RomCache)(
makeCacheStore({ schema: CacheSchema, empty: emptyCache }),
);
}
+17
View File
@@ -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<typeof RomConfigSchema>
>()("rom-manager/RomConfig") {
static readonly layer: Layer.Layer<RomConfig, never, PluginInfo> =
Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema }));
}
+234
View File
@@ -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<SyncReport>;
/** Scan + cached detect + cached art → desired entries. NO warming, NO reconcile. */
readonly preview: Effect.Effect<
{ entries: ReadonlyArray<ProviderEntry>; report: SyncReport },
unknown
>;
/** Emulator detection (cached; `force` re-probes — the UI's Detect button). */
readonly detect: (
force: boolean,
) => Effect.Effect<ReadonlyArray<DetectedEmulator>>;
readonly status: Effect.Effect<EngineStatus>;
/** The SSE feed: a full EngineStatus on every engine transition. */
readonly statusChanges: Stream.Stream<EngineStatus>;
}
export class RomSync extends Context.Service<RomSync, RomSyncService>()(
"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<string>) =>
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<DetectedEmulator>;
});
/** 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<string, ArtVerdict> } = {
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<ProviderEntry>,
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<EngineStatus> = 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;
});
}
+1 -1
View File
@@ -5,8 +5,8 @@ import {
LibretroProvider, LibretroProvider,
sanitizeName, sanitizeName,
} from "../src/art/libretro.js"; } from "../src/art/libretro.js";
import { parseTitle } from "../src/domain/titles.js";
import { platformById } from "../src/domain/platforms.js"; import { platformById } from "../src/domain/platforms.js";
import { parseTitle } from "../src/domain/titles.js";
const snes = platformById("snes")!; const snes = platformById("snes")!;
const nswitch = platformById("switch")!; const nswitch = platformById("switch")!;
+5 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"; 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", () => { describe("quotePosix", () => {
test("wraps a plain path in single quotes", () => { test("wraps a plain path in single quotes", () => {
+1 -1
View File
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { resolveConfig } from "@rom-manager/contract"; import { resolveConfig } from "@rom-manager/contract";
import type { ArtVerdict } from "../src/art/provider.js";
import type { DetectedEmulator } from "../src/domain/emulators.js"; import type { DetectedEmulator } from "../src/domain/emulators.js";
import { computeDesired } from "../src/domain/reconcile.js"; import { computeDesired } from "../src/domain/reconcile.js";
import type { RomCandidate } from "../src/domain/scanner.js"; import type { RomCandidate } from "../src/domain/scanner.js";
import type { ArtVerdict } from "../src/art/provider.js";
const RA: DetectedEmulator = { const RA: DetectedEmulator = {
id: "retroarch", id: "retroarch",
+1 -1
View File
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { platformById } from "../src/domain/platforms.js";
import { import {
type DirEnt, type DirEnt,
makeExcluder, makeExcluder,
type ScanFs, type ScanFs,
scanRoot, scanRoot,
} from "../src/domain/scanner.js"; } from "../src/domain/scanner.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 -1
View File
@@ -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/domain/titles.js";
import { platformById } from "../src/domain/platforms.js"; import { platformById } from "../src/domain/platforms.js";
import { parseTitle } from "../src/domain/titles.js";
const snes = platformById("snes")!; const snes = platformById("snes")!;
+1
View File
@@ -9,6 +9,7 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"resolveJsonModule": true,
"types": ["bun"] "types": ["bun"]
}, },
"include": ["src", "test"] "include": ["src", "test"]
+6
View File
@@ -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;
-169
View File
@@ -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 <pw> # 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<string, number>();
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<void> => {
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<void> => {
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 <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<void> => {
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 <scan|detect|preview|sync|uninstall|set-password>",
);
process.exitCode = cmd ? 2 : 0;
}
};
await main();
-151
View File
@@ -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$<saltB64>$<hashB64>`), 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<string, PlatformLaunch>;
/** Per-game overrides, keyed by `external_id`. */
gameOverrides?: Record<string, GameOverride>;
/** 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<SyncOptions>;
art?: Partial<ArtOptions>;
ui?: Partial<UiOptions>;
/** 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<string, PlatformLaunch>;
gameOverrides: Record<string, GameOverride>;
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({});
-7
View File
@@ -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";
-363
View File
@@ -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<typeof statePaths>;
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<typeof setInterval>;
private debounceTimer?: ReturnType<typeof setTimeout>;
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<void> {
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<DesiredResult & { detected: DetectedEmulator[] }> {
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<void> {
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<SyncReport | undefined> {
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<void> {
this.schedulePoll();
this.setupWatchers();
await this.sync("config-change");
}
/** Remove every entry this provider owns (CLI `uninstall`). */
async uninstall(): Promise<void> {
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,
};
}
}
-20
View File
@@ -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<unknown> =>
pf.request("PUT", `/library/provider/${PROVIDER_ID}`, entries);
/** Clean uninstall: remove every entry this provider owns. */
export const deleteProvider = (pf: Punktfunk): Promise<unknown> =>
pf.request("DELETE", `/library/provider/${PROVIDER_ID}`);
-65
View File
@@ -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<void> = 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<void>((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<void>)(pf);
pf.close();
}
-10
View File
@@ -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();
-68
View File
@@ -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: `<config_dir>/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 (`<config_dir>/rom-manager`), migrated away from on first run (state.ts). */
export const legacyPluginDir = (): string =>
path.join(hostConfigDir(), "rom-manager");
/** `<config_dir>/rom-manager/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
/** `<config_dir>/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");
};
-30
View File
@@ -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<PluginUiHandle> =>
servePluginUi(pf, {
id: PLUGIN_NAME,
title: UI_TITLE,
icon: UI_ICON,
version: opts?.version,
staticDir: resolveUiDir(import.meta.url),
fetch: makeRouter(engine),
});
-30
View File
@@ -1,30 +0,0 @@
// scrypt password hashing for the standalone fallback UI (design §9/§10.2). Format:
// `scrypt$<saltB64url>$<hashB64url>`. 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);
};
-108
View File
@@ -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<typeof setInterval> | 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<Response | undefined> => {
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);
}
};
-151
View File
@@ -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<void>;
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
const COOKIE = "pf_rm_session";
const loginPage = (error?: string): Response =>
new Response(
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>ROM Manager</title>
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
button{padding:.5rem;border-radius:.5rem;border:0;background:#6d28d9;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
<form method=post action="./login"><h1>ROM Manager</h1>${error ? `<div class=e>${error}</div>` : ""}
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
{
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);
},
};
};
-140
View File
@@ -1,140 +0,0 @@
// Config/cache persistence (design §9): the plugin owns two files under `<config_dir>/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<string, ArtVerdict>;
/** 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 (`<config_dir>/rom-manager`) to the runner-writable
* `<config_dir>/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()),
});
-16
View File
@@ -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;
}
};
-33
View File
@@ -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: <shell command> }`. */
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: `<platform>/<relpath>`). */
external_id: string;
title: string;
art?: Artwork;
launch?: LaunchSpec | null;
prep?: PrepCmd[];
}
-63
View File
@@ -1,63 +0,0 @@
// State persistence: the plugin writes under `<config_dir>/plugin-state/rom-manager`, and on first
// run migrates an operator config authored in the pre-0.2.1 `<config_dir>/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 <config_dir>/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" }]);
});
});
-12
View File
@@ -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"]
}