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
+71
View File
@@ -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));
});