Merge pull request 'fix(plugin-kit): regSubKeys could never return a subkey, and adopt biome' (#107) from worktree-kit-regsubkeys into main
audit / cargo-audit (push) Successful in 32s
audit / bun-audit (plugin-kit) (push) Failing after 23s
audit / bun-audit (sdk) (push) Successful in 20s
audit / docs-site-audit (push) Successful in 25s
audit / bun-audit (web) (push) Failing after 26s
audit / pnpm-audit (push) Successful in 12s
ci / rust-arm64 (push) Successful in 1m37s
ci / bun-nix (push) Successful in 28s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m29s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
audit / license-gate (push) Successful in 6m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
plugin-kit-publish / publish (push) Failing after 34s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Canceled after 8m5s
nix / flake (push) Canceled after 3m25s

Reviewed-on: #107
This commit was merged in pull request #107.
This commit is contained in:
2026-08-08 00:23:46 +00:00
33 changed files with 417 additions and 186 deletions
+7
View File
@@ -66,6 +66,13 @@ jobs:
test -f node_modules/@punktfunk/host/package.json
test -f node_modules/@punktfunk/host/dist/index.d.ts
# The kit had no biome config and no lint step, while every plugin repo that consumes it does
# — so its source drifted (unused imports, formatting) with nothing to catch it. Now gated
# here, on the same config and pinned biome version the plugins use.
- name: Lint & format
working-directory: plugin-kit
run: bun run check
- name: Typecheck
working-directory: plugin-kit
run: bun run typecheck
+46
View File
@@ -0,0 +1,46 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
"files": {
"ignoreUnknown": false,
"includes": ["**", "!dist", "!examples/**/dist", "!**/node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"suspicious": {
"noArrayIndexKey": "off"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"noLabelWithoutControl": "off"
},
"correctness": {
"useExhaustiveDependencies": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+21
View File
@@ -5,6 +5,7 @@
"": {
"name": "@punktfunk/plugin-kit",
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@punktfunk/host": "file:../sdk",
"@types/bun": "^1.3.0",
"@types/react": "^19.2.16",
@@ -25,6 +26,24 @@
"undici": "^8.9.0",
},
"packages": {
"@biomejs/biome": ["@biomejs/biome@2.5.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-x64": "2.5.7" }, "bin": { "biome": "bin/biome" } }, "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="],
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.98", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.98", "effect": "^4.0.0-beta.98" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-7bqawr/HqJWqQ8H/bHyzBlLPA3LIIm3Y+cGYlIxnC/QVK795QpiEXb7uxTnP7V7w49V0sBtTerv4/9ZjsMffLQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
@@ -49,6 +68,8 @@
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "bun2nix": "2.1.2", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "bun2nix": "2.1.2", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+4 -3
View File
@@ -55,9 +55,10 @@ const databaseCandidates = (): string[] => {
};
const findDatabase = (cfg: { databasePath?: string }): string | undefined =>
[...(cfg.databasePath ? [cfg.databasePath] : []), ...databaseCandidates()].find(
isFile,
);
[
...(cfg.databasePath ? [cfg.databasePath] : []),
...databaseCandidates(),
].find(isFile);
/**
* `<kind>/<slug>.jpg` across the current, legacy-cache and Flatpak Lutris roots.
+14 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/plugin-kit",
"version": "0.3.1",
"version": "0.3.2",
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
"type": "module",
"license": "MIT OR Apache-2.0",
@@ -13,7 +13,12 @@
"bugs": {
"url": "https://git.unom.io/unom/punktfunk/issues"
},
"keywords": ["punktfunk", "plugin", "framework", "effect"],
"keywords": [
"punktfunk",
"plugin",
"framework",
"effect"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
@@ -35,7 +40,10 @@
},
"./theme.css": "./dist/theme.css"
},
"files": ["dist", "README.md"],
"files": [
"dist",
"README.md"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
@@ -43,7 +51,8 @@
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json && cp src/theme.css dist/theme.css",
"test": "bun test",
"prepublishOnly": "bun run build"
"prepublishOnly": "bun run build",
"check": "biome check ."
},
"peerDependencies": {
"effect": "^4.0.0-beta.98",
@@ -56,6 +65,7 @@
}
},
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@punktfunk/host": "file:../sdk",
"@types/bun": "^1.3.0",
"@types/react": "^19.2.16",
+3 -9
View File
@@ -4,8 +4,8 @@
import * as fs from "node:fs";
import { Effect, Ref, Schema } from "effect";
import type { ConfigWriteError } from "./errors.js";
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
import { PluginInfo } from "./host-client.js";
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
export interface CacheStore<S extends Schema.Top> {
readonly get: Effect.Effect<S["Type"]>;
@@ -44,20 +44,14 @@ export const makeCacheStore = <S extends Schema.Top>(opts: {
const persist = (value: S["Type"]) =>
ensureStateDir(info.name).pipe(
Effect.flatMap(() =>
atomicWriteFile(file, JSON.stringify(value)),
),
Effect.flatMap(() => atomicWriteFile(file, JSON.stringify(value))),
);
const modify = <A>(f: (current: S["Type"]) => readonly [A, S["Type"]]) =>
Ref.modify(ref, (current) => {
const [a, next] = f(current);
return [[a, next] as const, next] as const;
}).pipe(
Effect.flatMap(([a, next]) =>
persist(next).pipe(Effect.as(a)),
),
);
}).pipe(Effect.flatMap(([a, next]) => persist(next).pipe(Effect.as(a))));
return {
get: Ref.get(ref),
+3 -7
View File
@@ -5,13 +5,13 @@
// ManagedRuntime + layer graph as the plugin entry, so commands reuse the exact services.
import { connect, type Punktfunk } from "@punktfunk/host";
import { Effect, Layer, ManagedRuntime } from "effect";
import { HostRequestError } from "./errors.js";
import {
type HostClient,
hostClientFromFacade,
type PluginInfo,
pluginInfoLayer,
} from "./host-client.js";
import { HostRequestError } from "./errors.js";
import { loggingLayer } from "./logging.js";
import type { PluginKitDef } from "./runtime.js";
@@ -62,9 +62,7 @@ export const runPluginCli = async <E, R>(opts: {
process.exit(name === undefined || name === "help" ? 0 : 2);
}
const pf = command.offline
? offlineFacade(opts.def.name)
: await connect();
const pf = command.offline ? offlineFacade(opts.def.name) : await connect();
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: opts.def.name, version: opts.def.version }),
@@ -82,9 +80,7 @@ export const runPluginCli = async <E, R>(opts: {
process.exitCode ??= 0;
} catch (e) {
const hint =
e instanceof HostRequestError
? " (is the Punktfunk host running?)"
: "";
e instanceof HostRequestError ? " (is the Punktfunk host running?)" : "";
console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`);
process.exitCode = 1;
} finally {
+3 -8
View File
@@ -16,8 +16,8 @@ import {
ConfigPermissionError,
type ConfigWriteError,
} from "./errors.js";
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
import { PluginInfo } from "./host-client.js";
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
export interface ConfigService<S extends Schema.Top> {
/** Decode the raw file with Schema defaults applied. Missing file → all defaults. */
@@ -36,10 +36,7 @@ export interface ConfigService<S extends Schema.Top> {
*/
readonly saveRaw: (
raw: unknown,
) => Effect.Effect<
S["Type"],
ConfigParseError | ConfigWriteError
>;
) => Effect.Effect<S["Type"], ConfigParseError | ConfigWriteError>;
/** Emits the decoded config after every successful `saveRaw`. */
readonly changes: Stream.Stream<S["Type"]>;
/** Absolute path of the config file (status views). */
@@ -95,9 +92,7 @@ export const makeConfigService = <S extends Schema.Top>(opts: {
const file = statePath(info.name, opts.fileName ?? "config.json");
const hub = yield* PubSub.unbounded<S["Type"]>();
const decode = (
raw: unknown,
): Effect.Effect<S["Type"], ConfigParseError> =>
const decode = (raw: unknown): Effect.Effect<S["Type"], ConfigParseError> =>
Schema.decodeUnknownEffect(opts.schema)(raw).pipe(
Effect.mapError(
(e) => new ConfigParseError({ path: file, issue: String(e) }),
+9 -9
View File
@@ -21,14 +21,13 @@ export interface HostClientService {
readonly facade: Punktfunk;
}
export class HostClient extends Context.Service<HostClient, HostClientService>()(
"@punktfunk/plugin-kit/HostClient",
) {}
export class HostClient extends Context.Service<
HostClient,
HostClientService
>()("@punktfunk/plugin-kit/HostClient") {}
/** Wrap the facade the runner hands to `main` (or `connect()` in the CLI/dev paths). */
export const hostClientFromFacade = (
pf: Punktfunk,
): Layer.Layer<HostClient> =>
export const hostClientFromFacade = (pf: Punktfunk): Layer.Layer<HostClient> =>
Layer.succeed(HostClient)({
request: (method, path, body) =>
Effect.tryPromise({
@@ -44,9 +43,10 @@ export interface PluginInfoService {
readonly version?: string;
}
export class PluginInfo extends Context.Service<PluginInfo, PluginInfoService>()(
"@punktfunk/plugin-kit/PluginInfo",
) {}
export class PluginInfo extends Context.Service<
PluginInfo,
PluginInfoService
>()("@punktfunk/plugin-kit/PluginInfo") {}
export const pluginInfoLayer = (
info: PluginInfoService,
+15 -14
View File
@@ -1,5 +1,18 @@
// @punktfunk/plugin-kit — Effect-based framework for punktfunk plugins.
export { type CacheStore, makeCacheStore } from "./cache-store.js";
export { type CliCommand, runPluginCli } from "./cli.js";
export { type ConfigService, makeConfigService } from "./config.js";
export * from "./errors.js";
export {
HostClient,
type HostClientService,
hostClientFromFacade,
PluginInfo,
type PluginInfoService,
pluginInfoLayer,
} from "./host-client.js";
export { loggingLayer } from "./logging.js";
export {
atomicWriteFile,
ensureStateDir,
@@ -7,17 +20,6 @@ export {
pluginStateDir,
statePath,
} from "./paths.js";
export {
HostClient,
hostClientFromFacade,
type HostClientService,
PluginInfo,
pluginInfoLayer,
type PluginInfoService,
} from "./host-client.js";
export { loggingLayer } from "./logging.js";
export { type ConfigService, makeConfigService } from "./config.js";
export { type CacheStore, makeCacheStore } from "./cache-store.js";
export {
Artwork,
DetectHint,
@@ -33,6 +35,7 @@ export {
type PluginKitDef,
runPluginKitDirect,
} from "./runtime.js";
export { type SseRouteOptions, sseRoute } from "./sse.js";
export {
type LastSync,
makeSyncEngine,
@@ -47,9 +50,7 @@ export {
deriveConfigJsonSchema,
httpApiEnv,
makeConfigHandler,
serveUi,
type ServeUiConfig,
type ServeUiOptions,
serveUi,
} from "./ui-server.js";
export { sseRoute, type SseRouteOptions } from "./sse.js";
export { type CliCommand, runPluginCli } from "./cli.js";
+16 -10
View File
@@ -6,12 +6,13 @@
// appending launcher entries, serving `__config` so the console renders settings without the plugin
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
// standard CLI verbs.
import type { PluginDef } from "@punktfunk/host";
import * as fs from "node:fs";
import { Duration, Effect, Layer, Schema, Stream } from "effect";
import type { PluginDef } from "@punktfunk/host";
import { Duration, Effect, type Schema, Stream } from "effect";
import { type CliCommand, runPluginCli } from "../cli.js";
import { type ConfigService, makeConfigService } from "../config.js";
import { HostClient, PluginInfo } from "../host-client.js";
import { HostClient, type PluginInfo } from "../host-client.js";
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
import { definePluginKit, type PluginKitDef } from "../runtime.js";
import { makeSyncEngine } from "../sync-engine.js";
@@ -103,8 +104,11 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
const debounce = def.debounce ?? Duration.seconds(3);
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
const config: Effect.Effect<ConfigService<S>, never, PluginInfo> =
makeConfigService({ schema: def.configSchema });
const config: Effect.Effect<
ConfigService<S>,
never,
PluginInfo
> = makeConfigService({ schema: def.configSchema });
/** Scan + launcher entries, in the order they should reach the host. */
const computeEntries = (
@@ -234,7 +238,8 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
}),
},
scan: {
summary: "scan and print what WOULD be synced (--preview for the JSON entries)",
summary:
"scan and print what WOULD be synced (--preview for the JSON entries)",
// Also offline: the point is to debug a scanner against real launcher files without
// touching the host's library.
offline: true,
@@ -289,9 +294,9 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
}
const baseline = yield* Effect.try({
try: () =>
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
typeof fromHostEntry
>[],
JSON.parse(
fs.readFileSync(compare as string, "utf8"),
) as ReturnType<typeof fromHostEntry>[],
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
});
const cfg = yield* (yield* config).load;
@@ -307,7 +312,8 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
}),
},
uninstall: {
summary: "remove this source's games from the host and release its store claim",
summary:
"remove this source's games from the host and release its store claim",
run: () =>
Effect.gen(function* () {
const provider = yield* ProviderClient;
+10 -2
View File
@@ -36,7 +36,10 @@ export const fileUrl = (p: string): string => {
* re-hashed; newer ones serve from an unpredictable per-asset-hash path, where this 404s and the
* client falls through to its next candidate. That degradation is intentional and pre-existing.
*/
export const steamCdnUrl = (appid: number, kind: ArtKind): string | undefined => {
export const steamCdnUrl = (
appid: number,
kind: ArtKind,
): string | undefined => {
// A non-Steam shortcut's appid has the high bit set and is never a real store appid — the CDN
// would only 404, so don't emit a URL that is guaranteed to fail.
if ((appid & 0x8000_0000) !== 0) return undefined;
@@ -81,7 +84,12 @@ export const findLocalArtFile = (
}
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
for (const name of localFilenames(kind)) {
const flat = path.join(root, "appcache", "librarycache", `${appid}_${name}`);
const flat = path.join(
root,
"appcache",
"librarycache",
`${appid}_${name}`,
);
if (isFile(flat)) return flat;
}
return undefined;
+1 -1
View File
@@ -101,7 +101,7 @@ export const confinedJoin = (base: string, rel: string): string | undefined => {
// Normalize separators so a Windows-shaped relative path is checked on any platform (a plugin
// may parse a Windows manifest while its tests run on Linux).
const parts = rel.split(/[\\/]/);
if (parts[0] === "" ) return undefined; // rooted
if (parts[0] === "") return undefined; // rooted
if (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
if (parts.some((p) => p === "..")) return undefined; // traversal
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
+2 -1
View File
@@ -6,8 +6,9 @@
// SSRF pivot from a process running on the operator's box (`http://169.254.169.254/…`, an internal
// service). The host learned this in the 2026-07-17 security review; a plugin fetching the same
// class of URL inherits the same rule. A rare legitimately-redirecting CDN just yields no art.
import { HostRequestError } from "../../errors.js";
import { Effect } from "effect";
import { HostRequestError } from "../../errors.js";
export interface FetchLimits {
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
+3 -2
View File
@@ -32,13 +32,13 @@ export {
} from "./http.js";
export {
parseRegQuery,
parseRegSubKeys,
type RegValue,
regQueryValue,
regQueryValues,
regSubKeys,
type RegValue,
validRegKey,
} from "./registry.js";
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
export {
crc32,
parseShortcuts,
@@ -46,6 +46,7 @@ export {
shortcutAppId,
shortcutGameId,
} from "./shortcuts.js";
export { openReadOnly, type ReadOnlyDb, withReadOnlyDb } from "./sqlite.js";
export {
steamLibraryDirs,
steamRoots,
+36 -7
View File
@@ -59,17 +59,46 @@ export const regQueryValue = (key: string, name: string): string | undefined =>
regQueryValues(key).find((v) => v.name.toLowerCase() === name.toLowerCase())
?.data;
/** The immediate SUBKEY paths under one HKLM key (GOG lists one subkey per installed game). */
/**
* `reg.exe` always echoes the FULL hive name in its output rows, never the abbreviation it was
* given: query `HKLM\SOFTWARE\…` and every line comes back `HKEY_LOCAL_MACHINE\SOFTWARE\…`.
*/
const HKLM_FULL = "HKEY_LOCAL_MACHINE\\";
/**
* Parse `reg.exe query <key>` output into the immediate subkey NAMES under `key`.
*
* Exported for tests, like {@link parseRegQuery}, and for the same reason — this is a text format
* that quietly breaks, and it did: the previous version matched output lines against the
* abbreviated `HKLM\…` prefix it was handed, while reg.exe prints `HKEY_LOCAL_MACHINE\…`. Nothing
* ever matched, so it returned `[]` on every machine, forever, and the one plugin that uses it
* (GOG) reported "no games installed" instead of failing. See the regSubKeys tests.
*
* Returns NAMES, not paths: the sole consumer composes `${key}\\${name}`, and a GOG subkey name IS
* the product id that becomes the entry's `external_id`.
*/
export const parseRegSubKeys = (stdout: string, key: string): string[] => {
const full = key.toUpperCase().startsWith(HKLM)
? HKLM_FULL + key.slice(HKLM.length)
: key;
const prefix = `${full.toLowerCase()}\\`;
return (
stdout
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.toLowerCase().startsWith(prefix))
.map((l) => l.slice(full.length + 1))
// Immediate children only — a deeper path still starts with the prefix.
.filter((name) => name !== "" && !name.includes("\\"))
);
};
/** The immediate SUBKEY NAMES under one HKLM key (GOG lists one subkey per installed game). */
export const regSubKeys = (key: string): string[] => {
if (!validRegKey(key)) return [];
const out = run(["query", key]);
if (out === undefined) return [];
const prefix = `${key.toLowerCase()}\\`;
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.toLowerCase().startsWith(prefix))
.filter((l) => !l.slice(key.length + 1).includes("\\"));
return parseRegSubKeys(out, key);
};
/**
+5 -2
View File
@@ -40,7 +40,10 @@ const readCStr = (buf: Uint8Array, c: Cursor): string | undefined => {
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
if (c.pos + 4 > buf.length) return undefined;
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(0, true);
const v = new DataView(buf.buffer, buf.byteOffset + c.pos, 4).getInt32(
0,
true,
);
c.pos += 4;
return v;
};
@@ -134,7 +137,7 @@ export const crc32 = (data: Uint8Array): number => {
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
}
}
return (~crc) >>> 0;
return ~crc >>> 0;
};
/**
+4 -2
View File
@@ -2,12 +2,14 @@
// matching the format the scripting runner journals (and what the previous hand-rolled
// plugin loggers emitted), so kit-based plugins read consistently in
// `journalctl --user -u punktfunk-scripting`.
import { Cause, Layer, Logger } from "effect";
import { Cause, type Layer, Logger } from "effect";
const render = (message: unknown): string => {
if (typeof message === "string") return message;
if (Array.isArray(message)) return message.map(render).join(" ");
return typeof message === "object" ? JSON.stringify(message) : String(message);
return typeof message === "object"
? JSON.stringify(message)
: String(message);
};
/** Replace the default logger with the runner-journal format. */
+3 -4
View File
@@ -7,9 +7,10 @@
// route init must read the last pathname segment — the hash is only a standalone-tab
// fallback. Navigation posts `pf-ui:navigate` so the console mirrors the route into its
// own URL (replace: true; the iframe src stays pinned — no reload loop).
import { useEffect, useState, type ReactNode } from "react";
import { Option, Schema } from "effect";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { type ReactNode, useEffect, useState } from "react";
/** `/plugin-ui/<id>` when served through the console proxy, "" in dev/standalone. */
export const resolvePluginBase = (): string => {
@@ -110,9 +111,7 @@ export interface ResultGateProps<A, E> {
* The one loading/error/success convention for plugin pages. Keeps showing the last
* value while a refresh is in flight (no skeleton flash on invalidation).
*/
export const ResultGate = <A, E>(
props: ResultGateProps<A, E>,
): ReactNode => {
export const ResultGate = <A, E>(props: ResultGateProps<A, E>): ReactNode => {
const { result } = props;
if (AsyncResult.isSuccess(result)) return props.children(result.value);
if (AsyncResult.isFailure(result)) {
+3 -1
View File
@@ -39,7 +39,9 @@ export interface ProviderClientService {
* Remove every entry this provider owns **and release its store claim** (the explicit-uninstall
* path). Releasing is what brings the host's built-in scanner back.
*/
readonly remove: (providerId: string) => Effect.Effect<void, HostRequestError>;
readonly remove: (
providerId: string,
) => Effect.Effect<void, HostRequestError>;
}
export class ProviderClient extends Context.Service<
+2 -2
View File
@@ -12,10 +12,10 @@
// plugin fiber (running scoped finalizers: UI deregistration, watcher close, cache flush)
// and bounds the whole teardown with `shutdownGraceMs` so `main` always resolves.
import {
connect,
definePlugin,
type PluginDef,
type Punktfunk,
connect,
} from "@punktfunk/host";
import {
Cause,
@@ -24,7 +24,7 @@ import {
Fiber,
Layer,
ManagedRuntime,
Scope,
type Scope,
} from "effect";
import {
type HostClient,
+1 -1
View File
@@ -2,7 +2,7 @@
// beta.99), so the status feed is a raw HttpRouter route beside the HttpApi contract —
// same wire shape the first-generation plugins used (`event: <name>` frames + comment
// pings), which is already proven through the console's reverse proxy.
import { Effect, Layer, Schedule, Stream } from "effect";
import { Effect, type Layer, Schedule, Stream } from "effect";
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
const encoder = new TextEncoder();
+6 -5
View File
@@ -13,7 +13,6 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import {
Cause,
type Duration,
Effect,
Exit,
@@ -40,7 +39,11 @@ export interface LastSync {
}
export type SyncOutcome<Report> =
| { readonly _tag: "Applied"; readonly report: Report; readonly count: number }
| {
readonly _tag: "Applied";
readonly report: Report;
readonly count: number;
}
| { readonly _tag: "Unchanged"; readonly report: Report }
| { readonly _tag: "AlreadyRunning" };
@@ -278,8 +281,6 @@ export const makeSyncEngine = <
status,
changes: Stream.fromPubSub(hub),
start: safeSync("startup").pipe(Effect.andThen(startLoops)),
reconfigure: startLoops.pipe(
Effect.andThen(safeSync("config-change")),
),
reconfigure: startLoops.pipe(Effect.andThen(safeSync("config-change"))),
} satisfies SyncEngine<Report>;
});
+5 -5
View File
@@ -3,7 +3,7 @@
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
// core-only env layers, no platform package, SPA fallthrough preserved.
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
import { Effect, FileSystem, Layer, Path, Schema, Scope } from "effect";
import { Effect, FileSystem, Layer, Path, Schema, type Scope } from "effect";
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
import type { ConfigService } from "./config.js";
import { UiServeError } from "./errors.js";
@@ -176,7 +176,9 @@ export const serveUi = (
Effect.promise(() => dispose()).pipe(Effect.ignore),
);
const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined;
const serveConfig = opts.config
? makeConfigHandler(opts.config)
: undefined;
const fetch = async (req: Request): Promise<Response | undefined> => {
const url = new URL(req.url);
@@ -203,9 +205,7 @@ export const serveUi = (
...(opts.staticDir !== undefined
? { staticDir: opts.staticDir }
: {}),
...(opts.category !== undefined
? { category: opts.category }
: {}),
...(opts.category !== undefined ? { category: opts.category } : {}),
fetch,
}),
catch: (cause) => new UiServeError({ cause }),
+1 -4
View File
@@ -71,10 +71,7 @@ describe("ConfigService", () => {
test("saveRaw persists the RAW shape verbatim (no defaults baked in)", async () => {
await withService((svc) => svc.saveRaw({ roots: ["/roms"] }));
const onDisk = JSON.parse(
fs.readFileSync(
path.join(pluginStateDir(PLUGIN), "config.json"),
"utf8",
),
fs.readFileSync(path.join(pluginStateDir(PLUGIN), "config.json"), "utf8"),
);
expect(onDisk).toEqual({ roots: ["/roms"] }); // no sync block materialized
const loaded = await withService((svc) => svc.load);
+28 -12
View File
@@ -25,7 +25,10 @@ const ScannerConfig = Schema.Struct({
}),
),
root: Schema.optionalKey(
Schema.String.annotate({ title: "Launcher root", description: "Absolute path." }),
Schema.String.annotate({
title: "Launcher root",
description: "Absolute path.",
}),
),
extraRoots: Schema.Array(Schema.String)
.annotate({ title: "Extra roots" })
@@ -36,7 +39,10 @@ const ScannerConfig = Schema.Struct({
),
),
launchers: Schema.Struct({
bigpicture: Schema.Boolean.annotate({ title: "Big Picture", default: true }),
bigpicture: Schema.Boolean.annotate({
title: "Big Picture",
default: true,
}),
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
}).pipe(
Schema.withDecodingDefaultKey(
@@ -80,7 +86,10 @@ describe("S2 — JSON Schema derivation for __config", () => {
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
expect(p.launchers).toMatchObject({
type: "object",
properties: { bigpicture: { type: "boolean" }, desktop: { type: "boolean" } },
properties: {
bigpicture: { type: "boolean" },
desktop: { type: "boolean" },
},
});
// A literal union derives a clean enum — prefer it over a union of strings.
expect(p.artSource).toMatchObject({
@@ -92,7 +101,9 @@ describe("S2 — JSON Schema derivation for __config", () => {
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
const p = props();
expect(p.enabled.title).toBe("Enable scanning");
expect(p.enabled.description).toBe("Whether this source contributes titles.");
expect(p.enabled.description).toBe(
"Whether this source contributes titles.",
);
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
// field shows the form no placeholder at all. Annotate every field.
expect(p.enabled.default).toBe(true);
@@ -120,13 +131,13 @@ describe("S2 — JSON Schema derivation for __config", () => {
expect(props().pollMinutes).toMatchObject({ type: "integer" });
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
// derives a four-way anyOf that no number input can render. Use Finite or Int.
const bad = deriveConfigJsonSchema(
Schema.Struct({ n: Schema.Number }),
) as { schema: { properties: { n: { anyOf?: unknown[] } } } };
const bad = deriveConfigJsonSchema(Schema.Struct({ n: Schema.Number })) as {
schema: { properties: { n: { anyOf?: unknown[] } } };
};
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
const ok = deriveConfigJsonSchema(
Schema.Struct({ n: Schema.Finite }),
) as { schema: { properties: { n: { type?: string } } } };
const ok = deriveConfigJsonSchema(Schema.Struct({ n: Schema.Finite })) as {
schema: { properties: { n: { type?: string } } };
};
expect(ok.schema.properties.n.type).toBe("number");
});
@@ -141,7 +152,10 @@ describe("S2 — JSON Schema derivation for __config", () => {
describe("__config wire contract", () => {
const withService = async <A>(
use: (handler: (req: Request) => Promise<Response>, file: string) => Promise<A>,
use: (
handler: (req: Request) => Promise<Response>,
file: string,
) => Promise<A>,
): Promise<A> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
@@ -150,7 +164,9 @@ describe("__config wire contract", () => {
const service = await Effect.runPromise(
makeConfigService({ schema: ScannerConfig }).pipe(
Effect.provide(
Layer.mergeAll(pluginInfoLayer({ name: "steam", version: "0.1.0" })),
Layer.mergeAll(
pluginInfoLayer({ name: "steam", version: "0.1.0" }),
),
),
),
);
+2 -1
View File
@@ -40,7 +40,8 @@ const pluginEntry = (over: Partial<ProviderEntry> = {}): ProviderEntry =>
art: {
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.jpg",
header: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg",
header:
"https://cdn.cloudflare.steamstatic.com/steam/apps/440/header.jpg",
},
platform: "PC",
...over,
+114 -20
View File
@@ -5,23 +5,24 @@
// and launches nothing. Where a Rust test exists, its assertions are carried over verbatim — the
// per-plugin parity harness (design M5) then checks the whole pipeline against a live host, but
// these catch a drift long before that.
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { 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 {
confinedJoin,
crc32,
fileUrl,
findGridArtFile,
findLocalArtFile,
fileUrl,
gridFilenames,
isSteamTool,
withReadOnlyDb,
openReadOnly,
parseAppManifest,
parseRegQuery,
parseRegSubKeys,
parseShortcuts,
readTextCapped,
shortcutAppId,
@@ -29,6 +30,7 @@ import {
steamCdnUrl,
vdfPaths,
vdfValue,
withReadOnlyDb,
} from "../src/library/parsers/index.js";
const tmp = (name: string): string => {
@@ -85,7 +87,9 @@ describe("text VDF / ACF", () => {
});
test("isSteamTool keeps runtimes out of a game library", () => {
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(true);
expect(isSteamTool(228980, "Steamworks Common Redistributables")).toBe(
true,
);
expect(isSteamTool(1628350, "Steam Linux Runtime 3.0 (sniper)")).toBe(true);
expect(isSteamTool(999, "Proton 9.0")).toBe(true);
expect(isSteamTool(999, "SteamVR")).toBe(true);
@@ -109,7 +113,12 @@ describe("binary shortcuts.vdf", () => {
parts.push(0);
};
const i32 = (v: number) => {
parts.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
parts.push(
v & 0xff,
(v >>> 8) & 0xff,
(v >>> 16) & 0xff,
(v >>> 24) & 0xff,
);
};
parts.push(0x00);
cstr("shortcuts");
@@ -205,9 +214,13 @@ describe("path confinement", () => {
path.join(base, "bin", "game.exe"),
);
// The three shapes a crafted goggame-*.info would use to point elsewhere.
expect(confinedJoin(base, "../../windows/system32/cmd.exe")).toBeUndefined();
expect(
confinedJoin(base, "../../windows/system32/cmd.exe"),
).toBeUndefined();
expect(confinedJoin(base, "/etc/passwd")).toBeUndefined();
expect(confinedJoin(base, "C:\\Windows\\system32\\cmd.exe")).toBeUndefined();
expect(
confinedJoin(base, "C:\\Windows\\system32\\cmd.exe"),
).toBeUndefined();
expect(confinedJoin(base, "")).toBeUndefined();
});
});
@@ -237,8 +250,14 @@ describe("art locations", () => {
test("grid filenames follow Steam's per-kind naming", () => {
expect(gridFilenames(570, "portrait")).toEqual(["570p.png", "570p.jpg"]);
expect(gridFilenames(570, "hero")).toEqual(["570_hero.png", "570_hero.jpg"]);
expect(gridFilenames(570, "logo")).toEqual(["570_logo.png", "570_logo.jpg"]);
expect(gridFilenames(570, "hero")).toEqual([
"570_hero.png",
"570_hero.jpg",
]);
expect(gridFilenames(570, "logo")).toEqual([
"570_logo.png",
"570_logo.jpg",
]);
expect(gridFilenames(570, "header")).toEqual(["570.png", "570.jpg"]);
});
@@ -307,8 +326,12 @@ describe("openReadOnly", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-sqlite-"));
const file = path.join(dir, "pga.db");
const seed = new Database(file);
seed.run("CREATE TABLE games (id INTEGER PRIMARY KEY, name TEXT, installed INT)");
seed.run("INSERT INTO games (id, name, installed) VALUES (1, 'Ubisoft Connect', 1)");
seed.run(
"CREATE TABLE games (id INTEGER PRIMARY KEY, name TEXT, installed INT)",
);
seed.run(
"INSERT INTO games (id, name, installed) VALUES (1, 'Ubisoft Connect', 1)",
);
seed.close();
try {
return use(file);
@@ -321,9 +344,9 @@ describe("openReadOnly", () => {
withDb((file) => {
const db = openReadOnly(file);
expect(db).toBeDefined();
expect(db?.query("SELECT id, name FROM games WHERE installed = 1")).toEqual([
{ id: 1, name: "Ubisoft Connect" },
]);
expect(
db?.query("SELECT id, name FROM games WHERE installed = 1"),
).toEqual([{ id: 1, name: "Ubisoft Connect" }]);
db?.close();
});
});
@@ -337,7 +360,9 @@ describe("openReadOnly", () => {
seed.run("INSERT INTO games (id) VALUES (7)");
seed.close();
try {
expect(openReadOnly(file)?.query("SELECT id FROM games")).toEqual([{ id: 7 }]);
expect(openReadOnly(file)?.query("SELECT id FROM games")).toEqual([
{ id: 7 },
]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -345,16 +370,20 @@ describe("openReadOnly", () => {
test("withReadOnlyDb reads, then closes", () => {
withDb((file) => {
expect(withReadOnlyDb(file, (h) => h.query("SELECT name FROM games"))).toEqual([
{ name: "Ubisoft Connect" },
]);
expect(
withReadOnlyDb(file, (h) => h.query("SELECT name FROM games")),
).toEqual([{ name: "Ubisoft Connect" }]);
});
});
// The "not installed" contract — an absent file is `undefined`, never a throw.
test("absent file is undefined, not an error", () => {
expect(openReadOnly(path.join(os.tmpdir(), "pf-kit-nope", "pga.db"))).toBeUndefined();
expect(withReadOnlyDb(path.join(os.tmpdir(), "pf-kit-nope", "pga.db"), () => 1)).toBeUndefined();
expect(
openReadOnly(path.join(os.tmpdir(), "pf-kit-nope", "pga.db")),
).toBeUndefined();
expect(
withReadOnlyDb(path.join(os.tmpdir(), "pf-kit-nope", "pga.db"), () => 1),
).toBeUndefined();
});
// Schema drift degrades to no rows rather than taking the plugin down.
@@ -366,3 +395,68 @@ describe("openReadOnly", () => {
});
});
});
// Subkey enumeration, against the output reg.exe ACTUALLY prints.
//
// This had no coverage and was broken end to end: it matched lines against the abbreviated
// `HKLM\…` prefix it was handed, but reg.exe echoes `HKEY_LOCAL_MACHINE\…`. Nothing ever matched,
// so it returned [] on every machine, and the GOG plugin — its only consumer — reported "no games
// installed" rather than failing. Caught on hardware by the parity gate: the host's built-in
// scanner found IRON NEST, the plugin found nothing.
//
// The fixture is the verbatim output from .173 (a blank line, then one subkey row).
describe("parseRegSubKeys", () => {
const KEY = "HKLM\\SOFTWARE\\WOW6432Node\\GOG.com\\Games";
test("returns subkey NAMES from real reg.exe output", () => {
const stdout = [
"",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games\\2013434102",
"",
].join("\r\n");
// The name is the GOG product id, and the consumer composes `${KEY}\\${name}`.
expect(parseRegSubKeys(stdout, KEY)).toEqual(["2013434102"]);
});
test("several subkeys, in order", () => {
const base = "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games";
const stdout = ["", `${base}\\1207658930`, `${base}\\2013434102`].join(
"\r\n",
);
expect(parseRegSubKeys(stdout, KEY)).toEqual(["1207658930", "2013434102"]);
});
// reg.exe /s output nests deeper; only immediate children are subkeys of this key.
test("ignores grandchildren", () => {
const base = "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games";
const stdout = [
"",
`${base}\\2013434102`,
`${base}\\2013434102\\tasks`,
].join("\r\n");
expect(parseRegSubKeys(stdout, KEY)).toEqual(["2013434102"]);
});
// The queried key itself is echoed as a header when it has values; it is not its own subkey.
test("does not return the queried key itself", () => {
const stdout = [
"",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games",
"",
].join("\r\n");
expect(parseRegSubKeys(stdout, KEY)).toEqual([]);
});
test("case-insensitive on the hive and path", () => {
const stdout =
"hkey_local_machine\\software\\wow6432node\\gog.com\\games\\42";
expect(parseRegSubKeys(stdout, KEY)).toEqual(["42"]);
});
test("no subkeys is empty, not a throw", () => {
expect(parseRegSubKeys("", KEY)).toEqual([]);
expect(
parseRegSubKeys("ERROR: The system was unable to find...", KEY),
).toEqual([]);
});
});
+2 -6
View File
@@ -45,9 +45,7 @@ describe("spike 2: client prefix through the console proxy", () => {
const captured: Array<string> = [];
class Api extends AtomHttpApi.Service<Api>()("SpikeApiPrepend", {
api,
httpClient: Layer.succeed(HttpClient.HttpClient)(
captureClient(captured),
),
httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)),
transformClient: HttpClient.mapRequest(
HttpClientRequest.prependUrl(PREFIX),
),
@@ -66,9 +64,7 @@ describe("spike 2: client prefix through the console proxy", () => {
const captured: Array<string> = [];
class Api extends AtomHttpApi.Service<Api>()("SpikeApiBaseUrl", {
api,
httpClient: Layer.succeed(HttpClient.HttpClient)(
captureClient(captured),
),
httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)),
baseUrl: PREFIX,
}) {}
+15 -9
View File
@@ -9,6 +9,8 @@
// 3. The real servePluginUi server (loopback, per-boot bearer secret, __health) proxies
// into the HttpApi handler end-to-end.
import { describe, expect, test } from "bun:test";
import type { Punktfunk } from "@punktfunk/host";
import { servePluginUi } from "@punktfunk/host";
import { Effect, Layer, Schema } from "effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
@@ -19,8 +21,6 @@ import {
HttpApiEndpoint,
HttpApiGroup,
} from "effect/unstable/httpapi";
import { servePluginUi } from "@punktfunk/host";
import type { Punktfunk } from "@punktfunk/host";
const Pong = Schema.Struct({ ok: Schema.Boolean, source: Schema.String });
const EchoIn = Schema.Struct({ msg: Schema.String });
@@ -90,8 +90,11 @@ describe("spike 1: HttpApi via toWebHandler on Bun", () => {
test("end-to-end behind servePluginUi (loopback + bearer secret)", async () => {
const { handler, dispose } = HttpRouter.toWebHandler(appLayer);
const registrations: Array<{ method: string; path: string; body: unknown }> =
[];
const registrations: Array<{
method: string;
path: string;
body: unknown;
}> = [];
// servePluginUi only touches pf.request — a recording stub is a faithful host.
const pf = {
request: async (method: string, path: string, body?: unknown) => {
@@ -116,15 +119,18 @@ describe("spike 1: HttpApi via toWebHandler on Bun", () => {
(r) => r.method === "PUT" && r.path === "/plugins/spike",
);
expect(reg).toBeDefined();
const secret = (reg?.body as { ui: { secret: string } }).ui.secret;
// Not `reg?.body`: the optional chain undoes the assertion above — if `reg` were
// undefined the `.ui` access would throw a TypeError instead of failing this test
// readably. The `expect` is what guarantees it, so assert it to the type system too.
if (!reg) throw new Error("registration not found");
const secret = (reg.body as { ui: { secret: string } }).ui.secret;
expect(secret.length).toBeGreaterThanOrEqual(16);
const auth = { authorization: `Bearer ${secret}` };
// Health endpoint is served by servePluginUi itself.
const health = await fetch(
`http://127.0.0.1:${ui.port}/__health`,
{ headers: auth },
);
const health = await fetch(`http://127.0.0.1:${ui.port}/__health`, {
headers: auth,
});
expect(health.status).toBe(200);
// HttpApi endpoint through the real server.
+6 -2
View File
@@ -44,7 +44,9 @@ describe("sseRoute (live, PubSub-backed)", () => {
const { handler, dispose } = HttpRouter.toWebHandler(
Layer.provide(routes, httpApiEnv),
);
const res = yield* Effect.promise(() => handler(new Request("http://127.0.0.1/api/events")));
const res = yield* Effect.promise(() =>
handler(new Request("http://127.0.0.1/api/events")),
);
expect(res.status).toBe(200);
// Publish only once the response is open — the real engine's pattern.
setTimeout(() => {
@@ -68,7 +70,9 @@ describe("sseRoute (live, PubSub-backed)", () => {
const { handler, dispose } = HttpRouter.toWebHandler(
Layer.provide(routes, httpApiEnv),
);
const res = yield* Effect.promise(() => handler(new Request("http://127.0.0.1/api/events")));
const res = yield* Effect.promise(() =>
handler(new Request("http://127.0.0.1/api/events")),
);
const body = yield* Effect.promise(() => readSome(res, 4000));
yield* Effect.promise(() => dispose());
return body;
+2 -6
View File
@@ -18,13 +18,9 @@ describe("sseRoute", () => {
Layer.provide(routes, httpApiEnv),
);
try {
const res = await handler(
new Request("http://127.0.0.1/api/events"),
);
const res = await handler(new Request("http://127.0.0.1/api/events"));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain(
"text/event-stream",
);
expect(res.headers.get("content-type")).toContain("text/event-stream");
const text = await res.text();
expect(text).toContain('event: status\ndata: {"tick":0}\n\n');
expect(text).toContain('event: status\ndata: {"tick":2}\n\n');
+25 -27
View File
@@ -1,6 +1,6 @@
// SyncEngine semantics: fingerprint skip, single-flight coalescing, status feed.
import { describe, expect, test } from "bun:test";
import { Duration, Effect, Fiber, Ref, Scope, Stream } from "effect";
import { Duration, Effect, Fiber, Ref, type Scope, Stream } from "effect";
import {
type LastSync,
makeSyncEngine,
@@ -19,33 +19,31 @@ const harness = (opts?: {
const applied = yield* Ref.make(0);
const last = yield* Ref.make<LastSync | undefined>(undefined);
const entries = opts?.entries ?? (() => ["a", "b"]);
const engine = yield* makeSyncEngine<Report, ReadonlyArray<string>, never>(
{
compute: () =>
Effect.suspend(() => {
const e = entries();
return Effect.succeed({
entries: e,
report: { included: e.length },
});
}).pipe(
opts?.computeDelayMs
? Effect.delay(Duration.millis(opts.computeDelayMs))
: (x) => x,
),
apply: () => Ref.update(applied, (n) => n + 1),
lastSync: {
get: Ref.get(last),
set: (l) => Ref.set(last, l),
},
settings: Effect.succeed({
pollInterval: Duration.minutes(60),
watch: false,
debounce: Duration.millis(10),
watchDirs: [],
}),
const engine = yield* makeSyncEngine<Report, ReadonlyArray<string>, never>({
compute: () =>
Effect.suspend(() => {
const e = entries();
return Effect.succeed({
entries: e,
report: { included: e.length },
});
}).pipe(
opts?.computeDelayMs
? Effect.delay(Duration.millis(opts.computeDelayMs))
: (x) => x,
),
apply: () => Ref.update(applied, (n) => n + 1),
lastSync: {
get: Ref.get(last),
set: (l) => Ref.set(last, l),
},
);
settings: Effect.succeed({
pollInterval: Duration.minutes(60),
watch: false,
debounce: Duration.millis(10),
watchDirs: [],
}),
});
return { engine, applied, last };
});