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:
2026-07-20 19:04:25 +02:00
parent d6cf5f3d36
commit 899028e20f
43 changed files with 811 additions and 1558 deletions
+97
View File
@@ -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,
});