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:
+18
-8
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-9
@@ -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,14 +85,15 @@ 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,
|
||||||
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
|
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** The SSE status feed — outside the HttpApi (no event-stream media type in httpapi).
|
/** The SSE status feed — outside the HttpApi (no event-stream media type in httpapi).
|
||||||
* Frames: `event: status`, data = EngineStatus JSON. */
|
* Frames: `event: status`, data = EngineStatus JSON. */
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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")!;
|
||||||
|
|||||||
@@ -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,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,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,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")!;
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Vendored
+6
@@ -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
@@ -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
@@ -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({});
|
|
||||||
@@ -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";
|
|
||||||
@@ -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
@@ -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}`);
|
|
||||||
@@ -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
@@ -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();
|
|
||||||
@@ -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");
|
|
||||||
};
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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()),
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -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" }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user