refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces

The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.

  contract/  Effect Schemas: the cross-process export contract (the C#
             exporter's other half, with the schema-version guard as a decode
             check), the config schema (one schema, Encoded = authored file
             shape, defaults only via withDecodingDefaultKey), domain DTOs, the
             HttpApi contract, API errors.
  plugin/    src/domain = the pure core (locate/read, art, reconcile), ported
             tests green before any Effect wiring; src/services = the kit's
             ConfigService/CacheStore/SyncEngine/ProviderClient; definePluginKit
             entry, kit CLI, auth-free dev API.

Preserved, because they are what makes playnite work at all: the ingest inbox
is still read FIRST (the de-privileged LocalService runner cannot reach the
interactive user's %APPDATA%), the host/dataurl/off art modes, and the
playnite:// launch hand-back. The C# exporter is untouched.

New: per-game include/exclude overrides, and an allow-listed /api/art proxy so
the SPA can render local Playnite covers — it serves only paths the currently
ingested export references.

Dropped: the standalone password UI server (console surface + CLI only, as in
rom-manager 0.3.0) and the devEntry flag.

Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
This commit is contained in:
2026-07-20 20:56:12 +02:00
parent e832201669
commit b81e03685a
59 changed files with 3408 additions and 2634 deletions
+156
View File
@@ -0,0 +1,156 @@
import { afterAll, 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 type { ArtOptions, ExportedGame } from "@playnite/contract";
import { artPaths, buildArtwork, fileToDataUrl } from "../src/domain/art.js";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-art-"));
const write = (name: string, bytes: Buffer): string => {
const p = path.join(dir, name);
fs.writeFileSync(p, bytes);
return p;
};
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
const DEFAULT_ART: ArtOptions = {
mode: "host",
maxBytes: 1_500_000,
includeBackground: false,
};
const game = (over: Partial<ExportedGame>): ExportedGame => ({
id: "11111111-1111-1111-1111-111111111111",
name: "Game",
installed: true,
hidden: false,
source: "Steam",
platforms: [],
cover: null,
background: null,
icon: null,
...over,
});
describe("fileToDataUrl", () => {
test("encodes a small png as a data URL", () => {
const p = write("cover.png", Buffer.from([1, 2, 3, 4]));
expect(fileToDataUrl(p, 1000)).toBe(
`data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}`,
);
});
test("drops files over the size cap", () => {
const p = write("big.jpg", Buffer.alloc(2000, 7));
expect(fileToDataUrl(p, 1000)).toBeNull();
});
test("rejects unknown extensions and null", () => {
const p = write("weird.xyz", Buffer.from([1]));
expect(fileToDataUrl(p, 1000)).toBeNull();
expect(fileToDataUrl(null, 1000)).toBeNull();
});
test("mime follows the extension", () => {
const p = write("c.jpeg", Buffer.from([9]));
expect(fileToDataUrl(p, 1000)?.startsWith("data:image/jpeg;base64,")).toBe(
true,
);
});
test("a remote reference passes through untouched", () => {
expect(fileToDataUrl("https://cdn.example/cover.jpg", 10)).toBe(
"https://cdn.example/cover.jpg",
);
});
});
describe("buildArtwork", () => {
test("off mode yields no art", () => {
const cover = write("c1.png", Buffer.from([1]));
expect(
buildArtwork(game({ cover }), { ...DEFAULT_ART, mode: "off" }),
).toBeUndefined();
});
test("host mode (default) emits the raw cover path — no bytes", () => {
const cover = write("c2.png", Buffer.from([1, 2]));
const art = buildArtwork(game({ cover }), DEFAULT_ART);
expect(art?.portrait).toBe(cover);
expect(art?.hero).toBeUndefined();
});
test("host mode includeBackground adds the hero path", () => {
const cover = write("c2b.png", Buffer.from([1]));
const background = write("b2b.jpg", Buffer.from([2]));
const art = buildArtwork(game({ cover, background }), {
...DEFAULT_ART,
includeBackground: true,
});
expect(art?.portrait).toBe(cover);
expect(art?.hero).toBe(background);
});
test("host mode with no cover yields undefined", () => {
expect(buildArtwork(game({ cover: null }), DEFAULT_ART)).toBeUndefined();
});
test("dataurl mode embeds the cover as a portrait data URL", () => {
const cover = write("c3.png", Buffer.from([1, 2]));
const art = buildArtwork(game({ cover }), {
...DEFAULT_ART,
mode: "dataurl",
});
expect(art?.portrait?.startsWith("data:image/png;base64,")).toBe(true);
expect(art?.hero).toBeUndefined();
});
test("dataurl includeBackground adds a hero data URL", () => {
const cover = write("c4.png", Buffer.from([1]));
const background = write("b4.jpg", Buffer.from([2]));
const art = buildArtwork(game({ cover, background }), {
...DEFAULT_ART,
mode: "dataurl",
includeBackground: true,
});
expect(art?.portrait).toBeTruthy();
expect(art?.hero?.startsWith("data:image/jpeg;base64,")).toBe(true);
});
test("dataurl mode with no readable art yields undefined", () => {
expect(
buildArtwork(game({ cover: "/does/not/exist.png" }), {
...DEFAULT_ART,
mode: "dataurl",
}),
).toBeUndefined();
});
});
describe("artPaths (the /api/art allow-list)", () => {
const exp = (games: ExportedGame[]) => ({
schema: 1,
generatedAt: "2026-01-01T00:00:00Z",
playnite: { version: "10", mode: "Desktop" },
games,
});
test("collects every local cover/background/icon the export references", () => {
const paths = artPaths(
exp([
game({ cover: "/a/cover.png", background: "/a/bg.jpg" }),
game({ icon: "/a/icon.ico" }),
]),
);
expect([...paths].sort()).toEqual([
"/a/bg.jpg",
"/a/cover.png",
"/a/icon.ico",
]);
});
test("excludes remote references and unservable extensions", () => {
const paths = artPaths(
exp([
game({ cover: "https://cdn.example/cover.jpg" }),
game({ cover: "/a/notes.txt" }),
]),
);
expect(paths.size).toBe(0);
});
test("a path the export never mentions is not allow-listed", () => {
const paths = artPaths(exp([game({ cover: "/a/cover.png" })]));
expect(paths.has("/etc/shadow")).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
// The config contract: defaults live ONLY in the schema, and the Encoded side is the
// authored file shape. A regression here would let a UI save bake defaults into the
// operator's config.json (the raw round-trip invariant the kit's ConfigService relies on).
import { describe, expect, test } from "bun:test";
import type { RawConfig } from "@playnite/contract";
import { PlayniteConfigSchema, resolveConfig } from "@playnite/contract";
import { Schema } from "effect";
const encode = Schema.encodeUnknownSync(PlayniteConfigSchema);
describe("resolveConfig", () => {
test("an empty file resolves to the documented defaults", () => {
expect(resolveConfig({})).toEqual({
sync: { pollMinutes: 5, watch: true, debounceMs: 1500 },
filter: {
installedOnly: true,
includeHidden: false,
sources: [],
excludeSources: [],
},
art: { mode: "host", maxBytes: 1_500_000, includeBackground: false },
gameOverrides: {},
});
});
test("a partial group keeps the untouched keys at their defaults", () => {
const config = resolveConfig({ art: { mode: "dataurl" } });
expect(config.art.mode).toBe("dataurl");
expect(config.art.maxBytes).toBe(1_500_000);
expect(config.sync.pollMinutes).toBe(5);
});
test("stale keys from an older config are tolerated and dropped", () => {
// 0.1.x wrote `ui` and `devEntry`; both are gone in 0.2.0.
const config = resolveConfig({
ui: { standalone: true, port: 47994 },
devEntry: true,
filter: { installedOnly: false },
});
expect(config.filter.installedOnly).toBe(false);
expect("ui" in config).toBe(false);
expect("devEntry" in config).toBe(false);
});
test("an invalid art mode is refused", () => {
expect(() => resolveConfig({ art: { mode: "inline" } })).toThrow();
});
});
describe("raw round-trip", () => {
test("encoding a resolved config omits every defaulted key", () => {
// This is what makes a UI save non-destructive: defaults never reach the file.
expect(encode(resolveConfig({}))).toEqual({});
});
test("encode is LOSSY for defaulted groups — which is why we never re-encode", () => {
// `encodingStrategy: "omit"` drops a defaulted key unconditionally, so a decode →
// encode round-trip would silently throw away authored values. The kit's
// ConfigService therefore persists the RAW payload verbatim and only *validates*
// by decoding; nothing in the plugin may write `encode(config)` back to disk.
const raw: RawConfig = {
playniteDir: "D:\\Playnite",
filter: { excludeSources: ["Xbox"] },
gameOverrides: {
"11111111-1111-1111-1111-111111111111": { exclude: true },
},
};
// Undefaulted keys survive…
expect(encode(resolveConfig(raw))).toEqual({ playniteDir: "D:\\Playnite" });
// …but the authored values themselves are only safe in the raw shape.
expect(resolveConfig(raw).filter.excludeSources).toEqual(["Xbox"]);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { afterAll, 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 { EXPORT_FILE, resolveConfig } from "@playnite/contract";
import { ExportError, locateExport, readExport } from "../src/domain/locate.js";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pf-playnite-loc-"));
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
/** Lay out a fake Playnite data dir with an export under ExtensionsData/<guid>/. */
const layout = (name: string, doc: unknown): string => {
const dir = path.join(root, name);
const ext = path.join(dir, "ExtensionsData", "abc-guid");
fs.mkdirSync(ext, { recursive: true });
if (doc !== undefined) {
fs.writeFileSync(path.join(ext, EXPORT_FILE), JSON.stringify(doc));
}
return dir;
};
const validDoc = {
schema: 1,
generatedAt: "2026-01-01T00:00:00Z",
playnite: { version: "10", mode: "Desktop" },
games: [],
};
describe("locateExport", () => {
test("finds the export under an overridden Playnite dir", () => {
const dir = layout("found", validDoc);
const loc = locateExport(resolveConfig({ playniteDir: dir }));
expect(loc.dir).toBe(dir);
expect(loc.file).toBe(
path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE),
);
expect(loc.mtime).toBeGreaterThan(0);
expect(loc.viaIngest).toBe(false);
});
test("reports the dir but no file when the exporter hasn't written yet", () => {
const dir = path.join(root, "empty");
fs.mkdirSync(dir, { recursive: true });
const loc = locateExport(resolveConfig({ playniteDir: dir }));
expect(loc.dir).toBe(dir);
expect(loc.file).toBeNull();
});
test("reads the ingest inbox first — the de-privileged runner's source", () => {
const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "pf-cfg-"));
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
process.env.PUNKTFUNK_CONFIG_DIR = cfg;
try {
const inbox = path.join(cfg, "ingest", "playnite");
fs.mkdirSync(inbox, { recursive: true });
fs.writeFileSync(path.join(inbox, EXPORT_FILE), JSON.stringify(validDoc));
// Even with a Playnite ExtensionsData dir present, the ingest drop wins.
const pd = layout("with-ingest", validDoc);
const loc = locateExport(resolveConfig({ playniteDir: pd }));
expect(loc.dir).toBe(inbox);
expect(loc.file).toBe(path.join(inbox, EXPORT_FILE));
expect(loc.viaIngest).toBe(true);
// The inbox always leads the probe list, so the UI can show it as the target.
expect(loc.candidates[0]).toBe(inbox);
} finally {
if (prev === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = prev;
fs.rmSync(cfg, { recursive: true, force: true });
}
});
});
describe("readExport", () => {
test("reads a valid export", () => {
const dir = layout("valid", validDoc);
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
expect(readExport(file).schema).toBe(1);
});
test("fills defaults for fields an older exporter omits", () => {
const dir = layout("sparse", { schema: 1, games: [{ id: "abc" }] });
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
const exp = readExport(file);
expect(exp.generatedAt).toBe("");
expect(exp.playnite).toEqual({ version: null, mode: null });
expect(exp.games[0]).toEqual({
id: "abc",
name: "",
installed: false,
hidden: false,
source: null,
platforms: [],
cover: null,
background: null,
icon: null,
});
});
test("rejects a future schema", () => {
const dir = layout("future", { ...validDoc, schema: 99 });
const file = path.join(dir, "ExtensionsData", "abc-guid", EXPORT_FILE);
expect(() => readExport(file)).toThrow(ExportError);
});
test("rejects non-JSON and foreign shapes", () => {
const bad = path.join(root, "bad.json");
fs.writeFileSync(bad, "{not json");
expect(() => readExport(bad)).toThrow(ExportError);
const foreign = path.join(root, "foreign.json");
fs.writeFileSync(foreign, JSON.stringify({ hello: "world" }));
expect(() => readExport(foreign)).toThrow(ExportError);
});
test("rejects a missing file", () => {
expect(() => readExport(path.join(root, "nope.json"))).toThrow(ExportError);
});
});
+226
View File
@@ -0,0 +1,226 @@
import { describe, expect, test } from "bun:test";
import type { ExportedGame, LibraryExport } from "@playnite/contract";
import { resolveConfig } from "@playnite/contract";
import {
computeEntries,
isGuid,
launchCommand,
} from "../src/domain/reconcile.js";
const G1 = "11111111-1111-1111-1111-111111111111";
const G2 = "22222222-2222-2222-2222-222222222222";
const game = (over: Partial<ExportedGame>): ExportedGame => ({
id: G1,
name: "Game",
installed: true,
hidden: false,
source: "Steam",
platforms: ["PC (Windows)"],
cover: null,
background: null,
icon: null,
...over,
});
const exp = (games: ExportedGame[]): LibraryExport => ({
schema: 1,
generatedAt: "2026-01-01T00:00:00Z",
playnite: { version: "10", mode: "Desktop" },
games,
});
const noArt = () => undefined;
describe("isGuid", () => {
test("accepts a real Playnite guid", () => {
expect(isGuid(G1)).toBe(true);
});
test("rejects junk", () => {
expect(isGuid("not-a-guid")).toBe(false);
expect(isGuid('570" & calc')).toBe(false);
});
});
describe("launchCommand", () => {
test("Windows uses start + playnite:// uri", () => {
expect(launchCommand(G1, "win32")).toEqual({
kind: "command",
value: `start "" "playnite://playnite/start/${G1}"`,
});
});
test("non-Windows falls back to xdg-open", () => {
expect(launchCommand(G1, "linux")?.value).toContain("xdg-open");
});
});
describe("computeEntries filtering", () => {
test("installedOnly drops uninstalled games", () => {
const { entries, report } = computeEntries({
exp: exp([game({ id: G1 }), game({ id: G2, installed: false })]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(entries.map((e) => e.external_id)).toEqual([G1]);
expect(report.skipped[0]?.reason).toBe("not installed");
});
test("includeHidden lets hidden games through", () => {
const hidden = exp([game({ id: G1, hidden: true })]);
expect(
computeEntries({
exp: hidden,
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
}).entries.length,
).toBe(0);
expect(
computeEntries({
exp: hidden,
config: resolveConfig({ filter: { includeHidden: true } }),
platform: "win32",
artFor: noArt,
}).entries.length,
).toBe(1);
});
test("source include/exclude is case-insensitive", () => {
const games = exp([
game({ id: G1, source: "Steam" }),
game({ id: G2, source: "GOG" }),
]);
const only = computeEntries({
exp: games,
config: resolveConfig({ filter: { sources: ["steam"] } }),
platform: "win32",
artFor: noArt,
});
expect(only.entries.map((e) => e.external_id)).toEqual([G1]);
const without = computeEntries({
exp: games,
config: resolveConfig({ filter: { excludeSources: ["GOG"] } }),
platform: "win32",
artFor: noArt,
});
expect(without.entries.map((e) => e.external_id)).toEqual([G1]);
});
test("invalid ids and empty titles are skipped", () => {
const { entries, report } = computeEntries({
exp: exp([game({ id: "bogus" }), game({ id: G2, name: " " })]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(entries.length).toBe(0);
expect(report.skipped.map((s) => s.reason).sort()).toEqual([
"invalid game id",
"no title",
]);
});
test("entries are title-sorted and per-source counted", () => {
const { entries, report } = computeEntries({
exp: exp([
game({ id: G1, name: "Zelda", source: "Nintendo" }),
game({ id: G2, name: "Antichamber", source: "Steam" }),
]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(entries.map((e) => e.title)).toEqual(["Antichamber", "Zelda"]);
expect(report.perSource).toEqual({ Nintendo: 1, Steam: 1 });
});
test("artFor result is attached", () => {
const { entries } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({}),
platform: "win32",
artFor: () => ({ portrait: "data:image/png;base64,AAAA" }),
});
expect(entries[0]?.art?.portrait).toBe("data:image/png;base64,AAAA");
});
test("the report carries the export's stamp", () => {
const { report } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({}),
platform: "win32",
artFor: noArt,
});
expect(report.generatedAt).toBe("2026-01-01T00:00:00Z");
expect(report.schemaVersion).toBe(1);
expect(report.considered).toBe(1);
});
});
describe("per-game overrides", () => {
test("an excluded game lands in `excluded`, not `skipped`", () => {
const { entries, report } = computeEntries({
exp: exp([
game({ id: G1, name: "Kept" }),
game({ id: G2, name: "Dropped" }),
]),
config: resolveConfig({ gameOverrides: { [G2]: { exclude: true } } }),
platform: "win32",
artFor: noArt,
});
expect(entries.map((e) => e.title)).toEqual(["Kept"]);
expect(report.excluded).toEqual([{ id: G2, title: "Dropped" }]);
expect(report.skipped).toEqual([]);
});
test("`exclude: false` is a no-op (the UI omits the key instead)", () => {
const { entries } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({ gameOverrides: { [G1]: { exclude: false } } }),
platform: "win32",
artFor: noArt,
});
expect(entries.length).toBe(1);
});
test("a game already filtered out is not double-reported as excluded", () => {
const { report } = computeEntries({
exp: exp([game({ id: G1, installed: false })]),
config: resolveConfig({ gameOverrides: { [G1]: { exclude: true } } }),
platform: "win32",
artFor: noArt,
});
expect(report.excluded).toEqual([]);
expect(report.skipped[0]?.reason).toBe("not installed");
});
});
describe("dataurl guard rail", () => {
test("warns once the inlined-cover payload would be too large", () => {
const many = Array.from({ length: 3001 }, (_, i) =>
game({
id: `${i.toString(16).padStart(8, "0")}-1111-1111-1111-111111111111`,
name: `Game ${i}`,
}),
);
const { report } = computeEntries({
exp: exp(many),
config: resolveConfig({ art: { mode: "dataurl" } }),
platform: "win32",
artFor: noArt,
});
expect(report.warnings[0]).toContain('art.mode "dataurl"');
});
test("host mode never warns", () => {
const { report } = computeEntries({
exp: exp([game({ id: G1 })]),
config: resolveConfig({ art: { mode: "host" } }),
platform: "win32",
artFor: noArt,
});
expect(report.warnings).toEqual([]);
});
});