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:
@@ -3,9 +3,9 @@
|
||||
// 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.
|
||||
|
||||
import type { ParsedTitle } from "../domain/titles.js";
|
||||
import type { Platform } from "../domain/platforms.js";
|
||||
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";
|
||||
|
||||
const BASE = "https://thumbnails.libretro.com";
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
// the rest of the run instead of hammering the API.
|
||||
|
||||
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 { Artwork } from "@punktfunk/plugin-kit/wire";
|
||||
import type { ParsedTitle } from "../domain/titles.js";
|
||||
import type { ArtProvider } from "./provider.js";
|
||||
|
||||
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.
|
||||
|
||||
import * as nodePath from "node:path";
|
||||
import type { PrepStep } from "@punktfunk/plugin-kit/wire";
|
||||
import type { DetectedEmulator } from "./emulators.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`. */
|
||||
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
|
||||
// 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 { ArtVerdict } from "../art/provider.js";
|
||||
import { killPrep } from "./close.js";
|
||||
import { type DetectedEmulator, resolveEmulators } from "./emulators.js";
|
||||
import { type EffectiveLaunch, resolveLaunch } from "./launch.js";
|
||||
import { type Platform, resolvePlatforms } from "./platforms.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 {
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user