feat: initial ROM & emulator manager plugin (M0–M5)
A punktfunk-plugin-* package that scans ROM directories, maps them to emulators, fetches box art, and reconciles them into the host game library under provider id `rom-manager` — with a console-hosted web UI. Engine (pure, unit-tested core): - Table-driven platform registry (~25 consoles) + emulator registry with best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch core discovery. - Scanner with disc folding (m3u/cue/gdi), archive gating, excludes. - No-Intro title parsing + optional per-platform region dedupe. - Security-critical quoting seam: POSIX single-quote + Windows double-quote with hostile-name refusal; ROM filenames never reach a shell un-quoted. - Pure desired-state reconcile (stable external_ids, scale guard, fingerprint skip) → full-replace PUT /library/provider/rom-manager. Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/ header, fuzzy match, operator API key) behind a provider seam, with keyless libretro-thumbnails as the zero-setup fallback (`auto` default). UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators / Games / Sync). Standalone password-gated fallback for host-only installs. CLI: scan / detect / preview / sync / uninstall / set-password. 48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end: scan → detect → reconcile PUT, fingerprint idempotence, and the standalone UI serving SPA + REST. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user