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
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 !== "."));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user