Files
punktfunk-plugin-rom-manager/src/cli.ts
T
enricobuehler 3a6c80558d
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled
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>
2026-07-18 10:09:18 +02:00

170 lines
5.3 KiB
TypeScript

#!/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();