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
+64 -48
View File
@@ -1,5 +1,7 @@
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
// 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 {
HttpApi,
@@ -7,9 +9,18 @@ import {
HttpApiGroup,
HttpApiSchema,
} from "effect/unstable/httpapi";
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
import { DetectedEmulator, EngineStatus, Platform, SyncReport } from "./domain.js";
import { ConfigInvalid, DetectFailed, ScanFailed, SyncInProgress } from "./errors.js";
import {
DetectedEmulator,
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.
* The UI resolves it locally by decoding through the SHARED RomConfigSchema. */
@@ -29,55 +40,60 @@ export const EmulatorsPayload = Schema.Struct({
});
export type EmulatorsPayload = typeof EmulatorsPayload.Type;
export const RomManagerApi = HttpApi.make("rom-manager").add(
HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
),
).add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
)
.add(
HttpApiEndpoint.put("put", "/api/config", {
payload: Schema.Unknown,
success: ConfigPayload,
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
export const RomManagerApi = HttpApi.make("rom-manager")
.add(
HttpApiGroup.make("status").add(
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
),
)
.add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
)
.add(
HttpApiEndpoint.put("put", "/api/config", {
payload: Schema.Unknown,
success: ConfigPayload,
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
}),
),
)
.add(
HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult,
error: ScanFailed.pipe(HttpApiSchema.status(500)),
}),
),
).add(
HttpApiGroup.make("library").add(
HttpApiEndpoint.get("preview", "/api/preview", {
success: PreviewResult,
error: ScanFailed.pipe(HttpApiSchema.status(500)),
}),
),
).add(
HttpApiGroup.make("catalog")
.add(
HttpApiEndpoint.get("platforms", "/api/platforms", {
success: Schema.Array(Platform),
}),
)
.add(
HttpApiEndpoint.get("emulators", "/api/emulators", {
success: EmulatorsPayload,
}),
)
.add(
HttpApiEndpoint.post("detect", "/api/detect", {
success: EmulatorsPayload,
error: DetectFailed.pipe(HttpApiSchema.status(500)),
)
.add(
HttpApiGroup.make("catalog")
.add(
HttpApiEndpoint.get("platforms", "/api/platforms", {
success: Schema.Array(Platform),
}),
)
.add(
HttpApiEndpoint.get("emulators", "/api/emulators", {
success: EmulatorsPayload,
}),
)
.add(
HttpApiEndpoint.post("detect", "/api/detect", {
success: EmulatorsPayload,
error: DetectFailed.pipe(HttpApiSchema.status(500)),
}),
),
)
.add(
HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport,
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
}),
),
).add(
HttpApiGroup.make("sync").add(
HttpApiEndpoint.post("run", "/api/sync", {
success: SyncReport,
error: SyncInProgress.pipe(HttpApiSchema.status(409)),
}),
),
);
);
/** The SSE status feed — outside the HttpApi (no event-stream media type in httpapi).
* Frames: `event: status`, data = EngineStatus JSON. */
+1 -4
View File
@@ -9,10 +9,7 @@
import { Effect, Schema } from "effect";
import { EmulatorDef, Platform } from "./domain.js";
const withDefault = <S extends Schema.Top>(
schema: S,
value: S["Encoded"],
) =>
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
schema.pipe(
Schema.withDecodingDefaultKey(Effect.succeed(value), {
encodingStrategy: "omit",
+10 -9
View File
@@ -2,7 +2,8 @@
// the derived types keep the original domain names so the pure core reads unchanged.
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;
/** Default emulator + core for a platform (operator-overridable per platform / per game). */
@@ -50,13 +51,13 @@ export const DetectedEmulator = Schema.Struct({
name: Schema.String,
template: Schema.String,
supportsArchives: Schema.Boolean,
contested: Schema.optionalKey(Schema.Boolean),
contested: Schema.optional(Schema.Boolean),
exeToken: Schema.String,
exePath: Schema.optionalKey(Schema.String),
appId: Schema.optionalKey(Schema.String),
exePath: Schema.optional(Schema.String),
appId: Schema.optional(Schema.String),
via: Schema.Literals(["path", "file", "flatpak"]),
coresDir: Schema.optionalKey(Schema.String),
cores: Schema.optionalKey(Schema.Array(Schema.String)),
coresDir: Schema.optional(Schema.String),
cores: Schema.optional(Schema.Array(Schema.String)),
});
export type DetectedEmulator = typeof DetectedEmulator.Type;
@@ -96,9 +97,9 @@ export const EngineStatus = Schema.Struct({
os: Os,
artProvider: Schema.NullOr(Schema.String),
syncing: Schema.Boolean,
lastSync: Schema.optionalKey(LastSync),
lastReport: Schema.optionalKey(SyncReport),
detectedAt: Schema.optionalKey(Schema.Number),
lastSync: Schema.optional(LastSync),
lastReport: Schema.optional(SyncReport),
detectedAt: Schema.optional(Schema.Number),
paths: Schema.Struct({
dir: Schema.String,
config: Schema.String,