3a6c80558d
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>
170 lines
5.3 KiB
TypeScript
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();
|