diff --git a/.gitea/workflows/plugin-kit-publish.yml b/.gitea/workflows/plugin-kit-publish.yml index d06f8b45..c03665f3 100644 --- a/.gitea/workflows/plugin-kit-publish.yml +++ b/.gitea/workflows/plugin-kit-publish.yml @@ -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 diff --git a/plugin-kit/biome.json b/plugin-kit/biome.json new file mode 100644 index 00000000..7fe93e0f --- /dev/null +++ b/plugin-kit/biome.json @@ -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" + } + } +} diff --git a/plugin-kit/bun.lock b/plugin-kit/bun.lock index c77a847b..b97b35d0 100644 --- a/plugin-kit/bun.lock +++ b/plugin-kit/bun.lock @@ -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=="], diff --git a/plugin-kit/examples/lutris-plugin.ts b/plugin-kit/examples/lutris-plugin.ts index 1752923b..55c83f10 100644 --- a/plugin-kit/examples/lutris-plugin.ts +++ b/plugin-kit/examples/lutris-plugin.ts @@ -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); /** * `/.jpg` across the current, legacy-cache and Flatpak Lutris roots. diff --git a/plugin-kit/package.json b/plugin-kit/package.json index c4cf6fad..d1058736 100644 --- a/plugin-kit/package.json +++ b/plugin-kit/package.json @@ -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", diff --git a/plugin-kit/src/cache-store.ts b/plugin-kit/src/cache-store.ts index 3be9d4ea..e16a222c 100644 --- a/plugin-kit/src/cache-store.ts +++ b/plugin-kit/src/cache-store.ts @@ -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 { readonly get: Effect.Effect; @@ -44,20 +44,14 @@ export const makeCacheStore = (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 = (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), diff --git a/plugin-kit/src/cli.ts b/plugin-kit/src/cli.ts index cb0dabab..e7315829 100644 --- a/plugin-kit/src/cli.ts +++ b/plugin-kit/src/cli.ts @@ -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 (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 (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 { diff --git a/plugin-kit/src/config.ts b/plugin-kit/src/config.ts index 9c9411a3..e1b8e9ca 100644 --- a/plugin-kit/src/config.ts +++ b/plugin-kit/src/config.ts @@ -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 { /** Decode the raw file with Schema defaults applied. Missing file → all defaults. */ @@ -36,10 +36,7 @@ export interface ConfigService { */ readonly saveRaw: ( raw: unknown, - ) => Effect.Effect< - S["Type"], - ConfigParseError | ConfigWriteError - >; + ) => Effect.Effect; /** Emits the decoded config after every successful `saveRaw`. */ readonly changes: Stream.Stream; /** Absolute path of the config file (status views). */ @@ -95,9 +92,7 @@ export const makeConfigService = (opts: { const file = statePath(info.name, opts.fileName ?? "config.json"); const hub = yield* PubSub.unbounded(); - const decode = ( - raw: unknown, - ): Effect.Effect => + const decode = (raw: unknown): Effect.Effect => Schema.decodeUnknownEffect(opts.schema)(raw).pipe( Effect.mapError( (e) => new ConfigParseError({ path: file, issue: String(e) }), diff --git a/plugin-kit/src/host-client.ts b/plugin-kit/src/host-client.ts index ea3caf5a..a127449e 100644 --- a/plugin-kit/src/host-client.ts +++ b/plugin-kit/src/host-client.ts @@ -21,14 +21,13 @@ export interface HostClientService { readonly facade: Punktfunk; } -export class HostClient extends Context.Service()( - "@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 => +export const hostClientFromFacade = (pf: Punktfunk): Layer.Layer => 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()( - "@punktfunk/plugin-kit/PluginInfo", -) {} +export class PluginInfo extends Context.Service< + PluginInfo, + PluginInfoService +>()("@punktfunk/plugin-kit/PluginInfo") {} export const pluginInfoLayer = ( info: PluginInfoService, diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index cd4aef44..d80b2bb2 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -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"; diff --git a/plugin-kit/src/library/define.ts b/plugin-kit/src/library/define.ts index 8978b12c..0ee87419 100644 --- a/plugin-kit/src/library/define.ts +++ b/plugin-kit/src/library/define.ts @@ -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 = ( const debounce = def.debounce ?? Duration.seconds(3); /** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */ - const config: Effect.Effect, never, PluginInfo> = - makeConfigService({ schema: def.configSchema }); + const config: Effect.Effect< + ConfigService, + 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 = ( }), }, 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 = ( } 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[], catch: (cause) => new Error(`cannot read ${compare}: ${cause}`), }); const cfg = yield* (yield* config).load; @@ -307,7 +312,8 @@ export const defineLibraryPlugin = ( }), }, 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; diff --git a/plugin-kit/src/library/parsers/art.ts b/plugin-kit/src/library/parsers/art.ts index f39ec22d..90cbdc88 100644 --- a/plugin-kit/src/library/parsers/art.ts +++ b/plugin-kit/src/library/parsers/art.ts @@ -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; diff --git a/plugin-kit/src/library/parsers/fs.ts b/plugin-kit/src/library/parsers/fs.ts index 288f9243..a8e6c1a3 100644 --- a/plugin-kit/src/library/parsers/fs.ts +++ b/plugin-kit/src/library/parsers/fs.ts @@ -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 !== ".")); diff --git a/plugin-kit/src/library/parsers/http.ts b/plugin-kit/src/library/parsers/http.ts index d1f629d9..db5daecb 100644 --- a/plugin-kit/src/library/parsers/http.ts +++ b/plugin-kit/src/library/parsers/http.ts @@ -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. */ diff --git a/plugin-kit/src/library/parsers/index.ts b/plugin-kit/src/library/parsers/index.ts index 697f2f12..2a20f2dc 100644 --- a/plugin-kit/src/library/parsers/index.ts +++ b/plugin-kit/src/library/parsers/index.ts @@ -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, diff --git a/plugin-kit/src/library/parsers/registry.ts b/plugin-kit/src/library/parsers/registry.ts index 4b420d66..5ebafe88 100644 --- a/plugin-kit/src/library/parsers/registry.ts +++ b/plugin-kit/src/library/parsers/registry.ts @@ -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 ` 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); }; /** diff --git a/plugin-kit/src/library/parsers/shortcuts.ts b/plugin-kit/src/library/parsers/shortcuts.ts index b1348952..e343ff43 100644 --- a/plugin-kit/src/library/parsers/shortcuts.ts +++ b/plugin-kit/src/library/parsers/shortcuts.ts @@ -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; }; /** diff --git a/plugin-kit/src/logging.ts b/plugin-kit/src/logging.ts index 8f2f8d8c..0e76a3e7 100644 --- a/plugin-kit/src/logging.ts +++ b/plugin-kit/src/logging.ts @@ -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. */ diff --git a/plugin-kit/src/react/index.tsx b/plugin-kit/src/react/index.tsx index 61f237c3..aeab2a49 100644 --- a/plugin-kit/src/react/index.tsx +++ b/plugin-kit/src/react/index.tsx @@ -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/` when served through the console proxy, "" in dev/standalone. */ export const resolvePluginBase = (): string => { @@ -110,9 +111,7 @@ export interface ResultGateProps { * 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 = ( - props: ResultGateProps, -): ReactNode => { +export const ResultGate = (props: ResultGateProps): ReactNode => { const { result } = props; if (AsyncResult.isSuccess(result)) return props.children(result.value); if (AsyncResult.isFailure(result)) { diff --git a/plugin-kit/src/reconcile.ts b/plugin-kit/src/reconcile.ts index 02dddd6c..391e922e 100644 --- a/plugin-kit/src/reconcile.ts +++ b/plugin-kit/src/reconcile.ts @@ -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; + readonly remove: ( + providerId: string, + ) => Effect.Effect; } export class ProviderClient extends Context.Service< diff --git a/plugin-kit/src/runtime.ts b/plugin-kit/src/runtime.ts index a2907ab0..341ce1ed 100644 --- a/plugin-kit/src/runtime.ts +++ b/plugin-kit/src/runtime.ts @@ -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, diff --git a/plugin-kit/src/sse.ts b/plugin-kit/src/sse.ts index 2a2d36f0..f91bde94 100644 --- a/plugin-kit/src/sse.ts +++ b/plugin-kit/src/sse.ts @@ -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: ` 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(); diff --git a/plugin-kit/src/sync-engine.ts b/plugin-kit/src/sync-engine.ts index d247356a..b4530fe5 100644 --- a/plugin-kit/src/sync-engine.ts +++ b/plugin-kit/src/sync-engine.ts @@ -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 = - | { 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; }); diff --git a/plugin-kit/src/ui-server.ts b/plugin-kit/src/ui-server.ts index ca12e9b1..ddfb8723 100644 --- a/plugin-kit/src/ui-server.ts +++ b/plugin-kit/src/ui-server.ts @@ -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 => { 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 }), diff --git a/plugin-kit/test/config.test.ts b/plugin-kit/test/config.test.ts index 2ea2b95d..87d3665e 100644 --- a/plugin-kit/test/config.test.ts +++ b/plugin-kit/test/config.test.ts @@ -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); diff --git a/plugin-kit/test/library-config.test.ts b/plugin-kit/test/library-config.test.ts index 9c398bf0..a07925d3 100644 --- a/plugin-kit/test/library-config.test.ts +++ b/plugin-kit/test/library-config.test.ts @@ -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 ( - use: (handler: (req: Request) => Promise, file: string) => Promise, + use: ( + handler: (req: Request) => Promise, + file: string, + ) => Promise, ): Promise => { 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" }), + ), ), ), ); diff --git a/plugin-kit/test/library-parity.test.ts b/plugin-kit/test/library-parity.test.ts index 2abc3f5a..641918ea 100644 --- a/plugin-kit/test/library-parity.test.ts +++ b/plugin-kit/test/library-parity.test.ts @@ -40,7 +40,8 @@ const pluginEntry = (over: Partial = {}): 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, diff --git a/plugin-kit/test/library-parsers.test.ts b/plugin-kit/test/library-parsers.test.ts index ae290c7d..55ead4bf 100644 --- a/plugin-kit/test/library-parsers.test.ts +++ b/plugin-kit/test/library-parsers.test.ts @@ -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([]); + }); +}); diff --git a/plugin-kit/test/spike-client-prefix.test.ts b/plugin-kit/test/spike-client-prefix.test.ts index 71fb37e0..7b5d468e 100644 --- a/plugin-kit/test/spike-client-prefix.test.ts +++ b/plugin-kit/test/spike-client-prefix.test.ts @@ -45,9 +45,7 @@ describe("spike 2: client prefix through the console proxy", () => { const captured: Array = []; class Api extends AtomHttpApi.Service()("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 = []; class Api extends AtomHttpApi.Service()("SpikeApiBaseUrl", { api, - httpClient: Layer.succeed(HttpClient.HttpClient)( - captureClient(captured), - ), + httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)), baseUrl: PREFIX, }) {} diff --git a/plugin-kit/test/spike-httpapi.test.ts b/plugin-kit/test/spike-httpapi.test.ts index a9435f94..dba8e229 100644 --- a/plugin-kit/test/spike-httpapi.test.ts +++ b/plugin-kit/test/spike-httpapi.test.ts @@ -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. diff --git a/plugin-kit/test/sse-live.test.ts b/plugin-kit/test/sse-live.test.ts index 65d08f7e..b7b1be53 100644 --- a/plugin-kit/test/sse-live.test.ts +++ b/plugin-kit/test/sse-live.test.ts @@ -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; diff --git a/plugin-kit/test/sse.test.ts b/plugin-kit/test/sse.test.ts index 7c847093..27c2074f 100644 --- a/plugin-kit/test/sse.test.ts +++ b/plugin-kit/test/sse.test.ts @@ -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'); diff --git a/plugin-kit/test/sync-engine.test.ts b/plugin-kit/test/sync-engine.test.ts index d53936d1..a5a93e76 100644 --- a/plugin-kit/test/sync-engine.test.ts +++ b/plugin-kit/test/sync-engine.test.ts @@ -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(undefined); const entries = opts?.entries ?? (() => ["a", "b"]); - const engine = yield* makeSyncEngine, 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, 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 }; });