style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.
Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.
Two deliberate differences from the plugin repos' copy:
* no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
the config; plugin-kit is a directory inside this one, and biome errors with
"couldn't find an ignore file". The `files.includes` exclusions cover it.
* `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.
`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.
Everything here is formatter/import churn except two real findings, both fixed:
* `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
never used;
* test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
`expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
`reg` been undefined the `.ui` access would throw a TypeError instead of
failing the test readably. Now asserted to the type system too.
Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.
Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
This commit is contained in:
@@ -66,6 +66,13 @@ jobs:
|
|||||||
test -f node_modules/@punktfunk/host/package.json
|
test -f node_modules/@punktfunk/host/package.json
|
||||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
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
|
- name: Typecheck
|
||||||
working-directory: plugin-kit
|
working-directory: plugin-kit
|
||||||
run: bun run typecheck
|
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",
|
"name": "@punktfunk/plugin-kit",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.2",
|
||||||
"@punktfunk/host": "file:../sdk",
|
"@punktfunk/host": "file:../sdk",
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
@@ -25,6 +26,24 @@
|
|||||||
"undici": "^8.9.0",
|
"undici": "^8.9.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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" } }],
|
||||||
|
|
||||||
|
"@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=="],
|
"@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=="],
|
"@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 =>
|
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.
|
* `<kind>/<slug>.jpg` across the current, legacy-cache and Flatpak Lutris roots.
|
||||||
|
|||||||
+13
-3
@@ -13,7 +13,12 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://git.unom.io/unom/punktfunk/issues"
|
"url": "https://git.unom.io/unom/punktfunk/issues"
|
||||||
},
|
},
|
||||||
"keywords": ["punktfunk", "plugin", "framework", "effect"],
|
"keywords": [
|
||||||
|
"punktfunk",
|
||||||
|
"plugin",
|
||||||
|
"framework",
|
||||||
|
"effect"
|
||||||
|
],
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -35,7 +40,10 @@
|
|||||||
},
|
},
|
||||||
"./theme.css": "./dist/theme.css"
|
"./theme.css": "./dist/theme.css"
|
||||||
},
|
},
|
||||||
"files": ["dist", "README.md"],
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||||
},
|
},
|
||||||
@@ -43,7 +51,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc -p tsconfig.build.json && cp src/theme.css dist/theme.css",
|
"build": "tsc -p tsconfig.build.json && cp src/theme.css dist/theme.css",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"prepublishOnly": "bun run build"
|
"prepublishOnly": "bun run build",
|
||||||
|
"check": "biome check ."
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "^4.0.0-beta.98",
|
"effect": "^4.0.0-beta.98",
|
||||||
@@ -56,6 +65,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.2",
|
||||||
"@punktfunk/host": "file:../sdk",
|
"@punktfunk/host": "file:../sdk",
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { Effect, Ref, Schema } from "effect";
|
import { Effect, Ref, Schema } from "effect";
|
||||||
import type { ConfigWriteError } from "./errors.js";
|
import type { ConfigWriteError } from "./errors.js";
|
||||||
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
|
||||||
import { PluginInfo } from "./host-client.js";
|
import { PluginInfo } from "./host-client.js";
|
||||||
|
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
||||||
|
|
||||||
export interface CacheStore<S extends Schema.Top> {
|
export interface CacheStore<S extends Schema.Top> {
|
||||||
readonly get: Effect.Effect<S["Type"]>;
|
readonly get: Effect.Effect<S["Type"]>;
|
||||||
@@ -44,20 +44,14 @@ export const makeCacheStore = <S extends Schema.Top>(opts: {
|
|||||||
|
|
||||||
const persist = (value: S["Type"]) =>
|
const persist = (value: S["Type"]) =>
|
||||||
ensureStateDir(info.name).pipe(
|
ensureStateDir(info.name).pipe(
|
||||||
Effect.flatMap(() =>
|
Effect.flatMap(() => atomicWriteFile(file, JSON.stringify(value))),
|
||||||
atomicWriteFile(file, JSON.stringify(value)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const modify = <A>(f: (current: S["Type"]) => readonly [A, S["Type"]]) =>
|
const modify = <A>(f: (current: S["Type"]) => readonly [A, S["Type"]]) =>
|
||||||
Ref.modify(ref, (current) => {
|
Ref.modify(ref, (current) => {
|
||||||
const [a, next] = f(current);
|
const [a, next] = f(current);
|
||||||
return [[a, next] as const, next] as const;
|
return [[a, next] as const, next] as const;
|
||||||
}).pipe(
|
}).pipe(Effect.flatMap(([a, next]) => persist(next).pipe(Effect.as(a))));
|
||||||
Effect.flatMap(([a, next]) =>
|
|
||||||
persist(next).pipe(Effect.as(a)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: Ref.get(ref),
|
get: Ref.get(ref),
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
// ManagedRuntime + layer graph as the plugin entry, so commands reuse the exact services.
|
// ManagedRuntime + layer graph as the plugin entry, so commands reuse the exact services.
|
||||||
import { connect, type Punktfunk } from "@punktfunk/host";
|
import { connect, type Punktfunk } from "@punktfunk/host";
|
||||||
import { Effect, Layer, ManagedRuntime } from "effect";
|
import { Effect, Layer, ManagedRuntime } from "effect";
|
||||||
|
import { HostRequestError } from "./errors.js";
|
||||||
import {
|
import {
|
||||||
type HostClient,
|
type HostClient,
|
||||||
hostClientFromFacade,
|
hostClientFromFacade,
|
||||||
type PluginInfo,
|
type PluginInfo,
|
||||||
pluginInfoLayer,
|
pluginInfoLayer,
|
||||||
} from "./host-client.js";
|
} from "./host-client.js";
|
||||||
import { HostRequestError } from "./errors.js";
|
|
||||||
import { loggingLayer } from "./logging.js";
|
import { loggingLayer } from "./logging.js";
|
||||||
import type { PluginKitDef } from "./runtime.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);
|
process.exit(name === undefined || name === "help" ? 0 : 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pf = command.offline
|
const pf = command.offline ? offlineFacade(opts.def.name) : await connect();
|
||||||
? offlineFacade(opts.def.name)
|
|
||||||
: await connect();
|
|
||||||
const base = Layer.mergeAll(
|
const base = Layer.mergeAll(
|
||||||
hostClientFromFacade(pf),
|
hostClientFromFacade(pf),
|
||||||
pluginInfoLayer({ name: opts.def.name, version: opts.def.version }),
|
pluginInfoLayer({ name: opts.def.name, version: opts.def.version }),
|
||||||
@@ -82,9 +80,7 @@ export const runPluginCli = async <E, R>(opts: {
|
|||||||
process.exitCode ??= 0;
|
process.exitCode ??= 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const hint =
|
const hint =
|
||||||
e instanceof HostRequestError
|
e instanceof HostRequestError ? " (is the Punktfunk host running?)" : "";
|
||||||
? " (is the Punktfunk host running?)"
|
|
||||||
: "";
|
|
||||||
console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`);
|
console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
ConfigPermissionError,
|
ConfigPermissionError,
|
||||||
type ConfigWriteError,
|
type ConfigWriteError,
|
||||||
} from "./errors.js";
|
} from "./errors.js";
|
||||||
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
|
||||||
import { PluginInfo } from "./host-client.js";
|
import { PluginInfo } from "./host-client.js";
|
||||||
|
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
||||||
|
|
||||||
export interface ConfigService<S extends Schema.Top> {
|
export interface ConfigService<S extends Schema.Top> {
|
||||||
/** Decode the raw file with Schema defaults applied. Missing file → all defaults. */
|
/** 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: (
|
readonly saveRaw: (
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
) => Effect.Effect<
|
) => Effect.Effect<S["Type"], ConfigParseError | ConfigWriteError>;
|
||||||
S["Type"],
|
|
||||||
ConfigParseError | ConfigWriteError
|
|
||||||
>;
|
|
||||||
/** Emits the decoded config after every successful `saveRaw`. */
|
/** Emits the decoded config after every successful `saveRaw`. */
|
||||||
readonly changes: Stream.Stream<S["Type"]>;
|
readonly changes: Stream.Stream<S["Type"]>;
|
||||||
/** Absolute path of the config file (status views). */
|
/** 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 file = statePath(info.name, opts.fileName ?? "config.json");
|
||||||
const hub = yield* PubSub.unbounded<S["Type"]>();
|
const hub = yield* PubSub.unbounded<S["Type"]>();
|
||||||
|
|
||||||
const decode = (
|
const decode = (raw: unknown): Effect.Effect<S["Type"], ConfigParseError> =>
|
||||||
raw: unknown,
|
|
||||||
): Effect.Effect<S["Type"], ConfigParseError> =>
|
|
||||||
Schema.decodeUnknownEffect(opts.schema)(raw).pipe(
|
Schema.decodeUnknownEffect(opts.schema)(raw).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(e) => new ConfigParseError({ path: file, issue: String(e) }),
|
(e) => new ConfigParseError({ path: file, issue: String(e) }),
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ export interface HostClientService {
|
|||||||
readonly facade: Punktfunk;
|
readonly facade: Punktfunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HostClient extends Context.Service<HostClient, HostClientService>()(
|
export class HostClient extends Context.Service<
|
||||||
"@punktfunk/plugin-kit/HostClient",
|
HostClient,
|
||||||
) {}
|
HostClientService
|
||||||
|
>()("@punktfunk/plugin-kit/HostClient") {}
|
||||||
|
|
||||||
/** Wrap the facade the runner hands to `main` (or `connect()` in the CLI/dev paths). */
|
/** Wrap the facade the runner hands to `main` (or `connect()` in the CLI/dev paths). */
|
||||||
export const hostClientFromFacade = (
|
export const hostClientFromFacade = (pf: Punktfunk): Layer.Layer<HostClient> =>
|
||||||
pf: Punktfunk,
|
|
||||||
): Layer.Layer<HostClient> =>
|
|
||||||
Layer.succeed(HostClient)({
|
Layer.succeed(HostClient)({
|
||||||
request: (method, path, body) =>
|
request: (method, path, body) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
@@ -44,9 +43,10 @@ export interface PluginInfoService {
|
|||||||
readonly version?: string;
|
readonly version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginInfo extends Context.Service<PluginInfo, PluginInfoService>()(
|
export class PluginInfo extends Context.Service<
|
||||||
"@punktfunk/plugin-kit/PluginInfo",
|
PluginInfo,
|
||||||
) {}
|
PluginInfoService
|
||||||
|
>()("@punktfunk/plugin-kit/PluginInfo") {}
|
||||||
|
|
||||||
export const pluginInfoLayer = (
|
export const pluginInfoLayer = (
|
||||||
info: PluginInfoService,
|
info: PluginInfoService,
|
||||||
|
|||||||
+15
-14
@@ -1,5 +1,18 @@
|
|||||||
// @punktfunk/plugin-kit — Effect-based framework for punktfunk plugins.
|
// @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 * from "./errors.js";
|
||||||
|
export {
|
||||||
|
HostClient,
|
||||||
|
type HostClientService,
|
||||||
|
hostClientFromFacade,
|
||||||
|
PluginInfo,
|
||||||
|
type PluginInfoService,
|
||||||
|
pluginInfoLayer,
|
||||||
|
} from "./host-client.js";
|
||||||
|
export { loggingLayer } from "./logging.js";
|
||||||
export {
|
export {
|
||||||
atomicWriteFile,
|
atomicWriteFile,
|
||||||
ensureStateDir,
|
ensureStateDir,
|
||||||
@@ -7,17 +20,6 @@ export {
|
|||||||
pluginStateDir,
|
pluginStateDir,
|
||||||
statePath,
|
statePath,
|
||||||
} from "./paths.js";
|
} 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 {
|
export {
|
||||||
Artwork,
|
Artwork,
|
||||||
DetectHint,
|
DetectHint,
|
||||||
@@ -33,6 +35,7 @@ export {
|
|||||||
type PluginKitDef,
|
type PluginKitDef,
|
||||||
runPluginKitDirect,
|
runPluginKitDirect,
|
||||||
} from "./runtime.js";
|
} from "./runtime.js";
|
||||||
|
export { type SseRouteOptions, sseRoute } from "./sse.js";
|
||||||
export {
|
export {
|
||||||
type LastSync,
|
type LastSync,
|
||||||
makeSyncEngine,
|
makeSyncEngine,
|
||||||
@@ -47,9 +50,7 @@ export {
|
|||||||
deriveConfigJsonSchema,
|
deriveConfigJsonSchema,
|
||||||
httpApiEnv,
|
httpApiEnv,
|
||||||
makeConfigHandler,
|
makeConfigHandler,
|
||||||
serveUi,
|
|
||||||
type ServeUiConfig,
|
type ServeUiConfig,
|
||||||
type ServeUiOptions,
|
type ServeUiOptions,
|
||||||
|
serveUi,
|
||||||
} from "./ui-server.js";
|
} 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
|
// 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
|
// shipping an SPA, registering under `category: "library"` so it stays out of the nav, and the
|
||||||
// standard CLI verbs.
|
// standard CLI verbs.
|
||||||
import type { PluginDef } from "@punktfunk/host";
|
|
||||||
import * as fs from "node:fs";
|
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 CliCommand, runPluginCli } from "../cli.js";
|
||||||
import { type ConfigService, makeConfigService } from "../config.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 { ProviderClient, type ProviderClientService } from "../reconcile.js";
|
||||||
import { definePluginKit, type PluginKitDef } from "../runtime.js";
|
import { definePluginKit, type PluginKitDef } from "../runtime.js";
|
||||||
import { makeSyncEngine } from "../sync-engine.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);
|
const debounce = def.debounce ?? Duration.seconds(3);
|
||||||
|
|
||||||
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
|
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
|
||||||
const config: Effect.Effect<ConfigService<S>, never, PluginInfo> =
|
const config: Effect.Effect<
|
||||||
makeConfigService({ schema: def.configSchema });
|
ConfigService<S>,
|
||||||
|
never,
|
||||||
|
PluginInfo
|
||||||
|
> = makeConfigService({ schema: def.configSchema });
|
||||||
|
|
||||||
/** Scan + launcher entries, in the order they should reach the host. */
|
/** Scan + launcher entries, in the order they should reach the host. */
|
||||||
const computeEntries = (
|
const computeEntries = (
|
||||||
@@ -234,7 +238,8 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
scan: {
|
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
|
// Also offline: the point is to debug a scanner against real launcher files without
|
||||||
// touching the host's library.
|
// touching the host's library.
|
||||||
offline: true,
|
offline: true,
|
||||||
@@ -289,9 +294,9 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
|
|||||||
}
|
}
|
||||||
const baseline = yield* Effect.try({
|
const baseline = yield* Effect.try({
|
||||||
try: () =>
|
try: () =>
|
||||||
JSON.parse(fs.readFileSync(compare as string, "utf8")) as ReturnType<
|
JSON.parse(
|
||||||
typeof fromHostEntry
|
fs.readFileSync(compare as string, "utf8"),
|
||||||
>[],
|
) as ReturnType<typeof fromHostEntry>[],
|
||||||
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
|
catch: (cause) => new Error(`cannot read ${compare}: ${cause}`),
|
||||||
});
|
});
|
||||||
const cfg = yield* (yield* config).load;
|
const cfg = yield* (yield* config).load;
|
||||||
@@ -307,7 +312,8 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
uninstall: {
|
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: () =>
|
run: () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const provider = yield* ProviderClient;
|
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
|
* 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.
|
* 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
|
// 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.
|
// would only 404, so don't emit a URL that is guaranteed to fail.
|
||||||
if ((appid & 0x8000_0000) !== 0) return undefined;
|
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.
|
// Older Steam wrote the files directly under `librarycache/` with the appid in the name.
|
||||||
for (const name of localFilenames(kind)) {
|
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;
|
if (isFile(flat)) return flat;
|
||||||
}
|
}
|
||||||
return undefined;
|
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
|
// 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).
|
// may parse a Windows manifest while its tests run on Linux).
|
||||||
const parts = rel.split(/[\\/]/);
|
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 (/^[A-Za-z]:$/.test(parts[0])) return undefined; // drive prefix
|
||||||
if (parts.some((p) => p === "..")) return undefined; // traversal
|
if (parts.some((p) => p === "..")) return undefined; // traversal
|
||||||
const joined = path.join(base, ...parts.filter((p) => p !== "" && p !== "."));
|
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
|
// 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
|
// 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.
|
// 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 { Effect } from "effect";
|
||||||
|
import { HostRequestError } from "../../errors.js";
|
||||||
|
|
||||||
export interface FetchLimits {
|
export interface FetchLimits {
|
||||||
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
|
/** Hard cap on the response body. Default 8 MiB — a cover never approaches it. */
|
||||||
|
|||||||
@@ -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. */
|
/** Read a little-endian int32, advancing 4 bytes. `undefined` if fewer than 4 remain. */
|
||||||
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
|
const readI32 = (buf: Uint8Array, c: Cursor): number | undefined => {
|
||||||
if (c.pos + 4 > buf.length) return 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;
|
c.pos += 4;
|
||||||
return v;
|
return v;
|
||||||
};
|
};
|
||||||
@@ -134,7 +137,7 @@ export const crc32 = (data: Uint8Array): number => {
|
|||||||
crc = (crc >>> 1) ^ (0xedb8_8320 & mask);
|
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
|
// matching the format the scripting runner journals (and what the previous hand-rolled
|
||||||
// plugin loggers emitted), so kit-based plugins read consistently in
|
// plugin loggers emitted), so kit-based plugins read consistently in
|
||||||
// `journalctl --user -u punktfunk-scripting`.
|
// `journalctl --user -u punktfunk-scripting`.
|
||||||
import { Cause, Layer, Logger } from "effect";
|
import { Cause, type Layer, Logger } from "effect";
|
||||||
|
|
||||||
const render = (message: unknown): string => {
|
const render = (message: unknown): string => {
|
||||||
if (typeof message === "string") return message;
|
if (typeof message === "string") return message;
|
||||||
if (Array.isArray(message)) return message.map(render).join(" ");
|
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. */
|
/** 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
|
// 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
|
// 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).
|
// 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 { Option, Schema } from "effect";
|
||||||
import { AsyncResult, Atom } from "effect/unstable/reactivity";
|
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. */
|
/** `/plugin-ui/<id>` when served through the console proxy, "" in dev/standalone. */
|
||||||
export const resolvePluginBase = (): string => {
|
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
|
* 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).
|
* value while a refresh is in flight (no skeleton flash on invalidation).
|
||||||
*/
|
*/
|
||||||
export const ResultGate = <A, E>(
|
export const ResultGate = <A, E>(props: ResultGateProps<A, E>): ReactNode => {
|
||||||
props: ResultGateProps<A, E>,
|
|
||||||
): ReactNode => {
|
|
||||||
const { result } = props;
|
const { result } = props;
|
||||||
if (AsyncResult.isSuccess(result)) return props.children(result.value);
|
if (AsyncResult.isSuccess(result)) return props.children(result.value);
|
||||||
if (AsyncResult.isFailure(result)) {
|
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
|
* 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.
|
* 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<
|
export class ProviderClient extends Context.Service<
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
// plugin fiber (running scoped finalizers: UI deregistration, watcher close, cache flush)
|
// plugin fiber (running scoped finalizers: UI deregistration, watcher close, cache flush)
|
||||||
// and bounds the whole teardown with `shutdownGraceMs` so `main` always resolves.
|
// and bounds the whole teardown with `shutdownGraceMs` so `main` always resolves.
|
||||||
import {
|
import {
|
||||||
|
connect,
|
||||||
definePlugin,
|
definePlugin,
|
||||||
type PluginDef,
|
type PluginDef,
|
||||||
type Punktfunk,
|
type Punktfunk,
|
||||||
connect,
|
|
||||||
} from "@punktfunk/host";
|
} from "@punktfunk/host";
|
||||||
import {
|
import {
|
||||||
Cause,
|
Cause,
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
Fiber,
|
Fiber,
|
||||||
Layer,
|
Layer,
|
||||||
ManagedRuntime,
|
ManagedRuntime,
|
||||||
Scope,
|
type Scope,
|
||||||
} from "effect";
|
} from "effect";
|
||||||
import {
|
import {
|
||||||
type HostClient,
|
type HostClient,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// beta.99), so the status feed is a raw HttpRouter route beside the HttpApi contract —
|
// 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
|
// same wire shape the first-generation plugins used (`event: <name>` frames + comment
|
||||||
// pings), which is already proven through the console's reverse proxy.
|
// 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";
|
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import {
|
import {
|
||||||
Cause,
|
|
||||||
type Duration,
|
type Duration,
|
||||||
Effect,
|
Effect,
|
||||||
Exit,
|
Exit,
|
||||||
@@ -40,7 +39,11 @@ export interface LastSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SyncOutcome<Report> =
|
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: "Unchanged"; readonly report: Report }
|
||||||
| { readonly _tag: "AlreadyRunning" };
|
| { readonly _tag: "AlreadyRunning" };
|
||||||
|
|
||||||
@@ -278,8 +281,6 @@ export const makeSyncEngine = <
|
|||||||
status,
|
status,
|
||||||
changes: Stream.fromPubSub(hub),
|
changes: Stream.fromPubSub(hub),
|
||||||
start: safeSync("startup").pipe(Effect.andThen(startLoops)),
|
start: safeSync("startup").pipe(Effect.andThen(startLoops)),
|
||||||
reconfigure: startLoops.pipe(
|
reconfigure: startLoops.pipe(Effect.andThen(safeSync("config-change"))),
|
||||||
Effect.andThen(safeSync("config-change")),
|
|
||||||
),
|
|
||||||
} satisfies SyncEngine<Report>;
|
} satisfies SyncEngine<Report>;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
|
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
|
||||||
// core-only env layers, no platform package, SPA fallthrough preserved.
|
// core-only env layers, no platform package, SPA fallthrough preserved.
|
||||||
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
|
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 { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
|
||||||
import type { ConfigService } from "./config.js";
|
import type { ConfigService } from "./config.js";
|
||||||
import { UiServeError } from "./errors.js";
|
import { UiServeError } from "./errors.js";
|
||||||
@@ -176,7 +176,9 @@ export const serveUi = (
|
|||||||
Effect.promise(() => dispose()).pipe(Effect.ignore),
|
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 fetch = async (req: Request): Promise<Response | undefined> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -203,9 +205,7 @@ export const serveUi = (
|
|||||||
...(opts.staticDir !== undefined
|
...(opts.staticDir !== undefined
|
||||||
? { staticDir: opts.staticDir }
|
? { staticDir: opts.staticDir }
|
||||||
: {}),
|
: {}),
|
||||||
...(opts.category !== undefined
|
...(opts.category !== undefined ? { category: opts.category } : {}),
|
||||||
? { category: opts.category }
|
|
||||||
: {}),
|
|
||||||
fetch,
|
fetch,
|
||||||
}),
|
}),
|
||||||
catch: (cause) => new UiServeError({ cause }),
|
catch: (cause) => new UiServeError({ cause }),
|
||||||
|
|||||||
@@ -71,10 +71,7 @@ describe("ConfigService", () => {
|
|||||||
test("saveRaw persists the RAW shape verbatim (no defaults baked in)", async () => {
|
test("saveRaw persists the RAW shape verbatim (no defaults baked in)", async () => {
|
||||||
await withService((svc) => svc.saveRaw({ roots: ["/roms"] }));
|
await withService((svc) => svc.saveRaw({ roots: ["/roms"] }));
|
||||||
const onDisk = JSON.parse(
|
const onDisk = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(path.join(pluginStateDir(PLUGIN), "config.json"), "utf8"),
|
||||||
path.join(pluginStateDir(PLUGIN), "config.json"),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(onDisk).toEqual({ roots: ["/roms"] }); // no sync block materialized
|
expect(onDisk).toEqual({ roots: ["/roms"] }); // no sync block materialized
|
||||||
const loaded = await withService((svc) => svc.load);
|
const loaded = await withService((svc) => svc.load);
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ const ScannerConfig = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
root: Schema.optionalKey(
|
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)
|
extraRoots: Schema.Array(Schema.String)
|
||||||
.annotate({ title: "Extra roots" })
|
.annotate({ title: "Extra roots" })
|
||||||
@@ -36,7 +39,10 @@ const ScannerConfig = Schema.Struct({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
launchers: 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 }),
|
desktop: Schema.Boolean.annotate({ title: "Desktop", default: false }),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Schema.withDecodingDefaultKey(
|
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.
|
// A nested object stays nested — the form renders a fieldset, not a JSON blob.
|
||||||
expect(p.launchers).toMatchObject({
|
expect(p.launchers).toMatchObject({
|
||||||
type: "object",
|
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.
|
// A literal union derives a clean enum — prefer it over a union of strings.
|
||||||
expect(p.artSource).toMatchObject({
|
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", () => {
|
test("annotations pass through — they are the ONLY source of labels and defaults", () => {
|
||||||
const p = props();
|
const p = props();
|
||||||
expect(p.enabled.title).toBe("Enable scanning");
|
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
|
// The derivation does NOT infer `default` from withDecodingDefaultKey, so an un-annotated
|
||||||
// field shows the form no placeholder at all. Annotate every field.
|
// field shows the form no placeholder at all. Annotate every field.
|
||||||
expect(p.enabled.default).toBe(true);
|
expect(p.enabled.default).toBe(true);
|
||||||
@@ -120,13 +131,13 @@ describe("S2 — JSON Schema derivation for __config", () => {
|
|||||||
expect(props().pollMinutes).toMatchObject({ type: "integer" });
|
expect(props().pollMinutes).toMatchObject({ type: "integer" });
|
||||||
// The trap, pinned: Schema.Number's ENCODED form admits "NaN"/"Infinity"/"-Infinity", so it
|
// 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.
|
// derives a four-way anyOf that no number input can render. Use Finite or Int.
|
||||||
const bad = deriveConfigJsonSchema(
|
const bad = deriveConfigJsonSchema(Schema.Struct({ n: Schema.Number })) as {
|
||||||
Schema.Struct({ n: Schema.Number }),
|
schema: { properties: { n: { anyOf?: unknown[] } } };
|
||||||
) as { schema: { properties: { n: { anyOf?: unknown[] } } } };
|
};
|
||||||
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
|
expect(Array.isArray(bad.schema.properties.n.anyOf)).toBe(true);
|
||||||
const ok = deriveConfigJsonSchema(
|
const ok = deriveConfigJsonSchema(Schema.Struct({ n: Schema.Finite })) as {
|
||||||
Schema.Struct({ n: Schema.Finite }),
|
schema: { properties: { n: { type?: string } } };
|
||||||
) as { schema: { properties: { n: { type?: string } } } };
|
};
|
||||||
expect(ok.schema.properties.n.type).toBe("number");
|
expect(ok.schema.properties.n.type).toBe("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +152,10 @@ describe("S2 — JSON Schema derivation for __config", () => {
|
|||||||
|
|
||||||
describe("__config wire contract", () => {
|
describe("__config wire contract", () => {
|
||||||
const withService = async <A>(
|
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> => {
|
): Promise<A> => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-cfg-"));
|
||||||
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
|
const prev = process.env.PUNKTFUNK_CONFIG_DIR;
|
||||||
@@ -150,7 +164,9 @@ describe("__config wire contract", () => {
|
|||||||
const service = await Effect.runPromise(
|
const service = await Effect.runPromise(
|
||||||
makeConfigService({ schema: ScannerConfig }).pipe(
|
makeConfigService({ schema: ScannerConfig }).pipe(
|
||||||
Effect.provide(
|
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: {
|
art: {
|
||||||
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
|
portrait: "file:///home/u/.steam/appcache/librarycache/440/a/p.jpg",
|
||||||
hero: "https://cdn.cloudflare.steamstatic.com/steam/apps/440/library_hero.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",
|
platform: "PC",
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ describe("spike 2: client prefix through the console proxy", () => {
|
|||||||
const captured: Array<string> = [];
|
const captured: Array<string> = [];
|
||||||
class Api extends AtomHttpApi.Service<Api>()("SpikeApiPrepend", {
|
class Api extends AtomHttpApi.Service<Api>()("SpikeApiPrepend", {
|
||||||
api,
|
api,
|
||||||
httpClient: Layer.succeed(HttpClient.HttpClient)(
|
httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)),
|
||||||
captureClient(captured),
|
|
||||||
),
|
|
||||||
transformClient: HttpClient.mapRequest(
|
transformClient: HttpClient.mapRequest(
|
||||||
HttpClientRequest.prependUrl(PREFIX),
|
HttpClientRequest.prependUrl(PREFIX),
|
||||||
),
|
),
|
||||||
@@ -66,9 +64,7 @@ describe("spike 2: client prefix through the console proxy", () => {
|
|||||||
const captured: Array<string> = [];
|
const captured: Array<string> = [];
|
||||||
class Api extends AtomHttpApi.Service<Api>()("SpikeApiBaseUrl", {
|
class Api extends AtomHttpApi.Service<Api>()("SpikeApiBaseUrl", {
|
||||||
api,
|
api,
|
||||||
httpClient: Layer.succeed(HttpClient.HttpClient)(
|
httpClient: Layer.succeed(HttpClient.HttpClient)(captureClient(captured)),
|
||||||
captureClient(captured),
|
|
||||||
),
|
|
||||||
baseUrl: PREFIX,
|
baseUrl: PREFIX,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
// 3. The real servePluginUi server (loopback, per-boot bearer secret, __health) proxies
|
// 3. The real servePluginUi server (loopback, per-boot bearer secret, __health) proxies
|
||||||
// into the HttpApi handler end-to-end.
|
// into the HttpApi handler end-to-end.
|
||||||
import { describe, expect, test } from "bun:test";
|
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 { Effect, Layer, Schema } from "effect";
|
||||||
import * as FileSystem from "effect/FileSystem";
|
import * as FileSystem from "effect/FileSystem";
|
||||||
import * as Path from "effect/Path";
|
import * as Path from "effect/Path";
|
||||||
@@ -19,8 +21,6 @@ import {
|
|||||||
HttpApiEndpoint,
|
HttpApiEndpoint,
|
||||||
HttpApiGroup,
|
HttpApiGroup,
|
||||||
} from "effect/unstable/httpapi";
|
} 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 Pong = Schema.Struct({ ok: Schema.Boolean, source: Schema.String });
|
||||||
const EchoIn = Schema.Struct({ msg: 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 () => {
|
test("end-to-end behind servePluginUi (loopback + bearer secret)", async () => {
|
||||||
const { handler, dispose } = HttpRouter.toWebHandler(appLayer);
|
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.
|
// servePluginUi only touches pf.request — a recording stub is a faithful host.
|
||||||
const pf = {
|
const pf = {
|
||||||
request: async (method: string, path: string, body?: unknown) => {
|
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",
|
(r) => r.method === "PUT" && r.path === "/plugins/spike",
|
||||||
);
|
);
|
||||||
expect(reg).toBeDefined();
|
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);
|
expect(secret.length).toBeGreaterThanOrEqual(16);
|
||||||
const auth = { authorization: `Bearer ${secret}` };
|
const auth = { authorization: `Bearer ${secret}` };
|
||||||
|
|
||||||
// Health endpoint is served by servePluginUi itself.
|
// Health endpoint is served by servePluginUi itself.
|
||||||
const health = await fetch(
|
const health = await fetch(`http://127.0.0.1:${ui.port}/__health`, {
|
||||||
`http://127.0.0.1:${ui.port}/__health`,
|
headers: auth,
|
||||||
{ headers: auth },
|
});
|
||||||
);
|
|
||||||
expect(health.status).toBe(200);
|
expect(health.status).toBe(200);
|
||||||
|
|
||||||
// HttpApi endpoint through the real server.
|
// HttpApi endpoint through the real server.
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ describe("sseRoute (live, PubSub-backed)", () => {
|
|||||||
const { handler, dispose } = HttpRouter.toWebHandler(
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
Layer.provide(routes, httpApiEnv),
|
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);
|
expect(res.status).toBe(200);
|
||||||
// Publish only once the response is open — the real engine's pattern.
|
// Publish only once the response is open — the real engine's pattern.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -68,7 +70,9 @@ describe("sseRoute (live, PubSub-backed)", () => {
|
|||||||
const { handler, dispose } = HttpRouter.toWebHandler(
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
Layer.provide(routes, httpApiEnv),
|
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));
|
const body = yield* Effect.promise(() => readSome(res, 4000));
|
||||||
yield* Effect.promise(() => dispose());
|
yield* Effect.promise(() => dispose());
|
||||||
return body;
|
return body;
|
||||||
|
|||||||
@@ -18,13 +18,9 @@ describe("sseRoute", () => {
|
|||||||
Layer.provide(routes, httpApiEnv),
|
Layer.provide(routes, httpApiEnv),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const res = await handler(
|
const res = await handler(new Request("http://127.0.0.1/api/events"));
|
||||||
new Request("http://127.0.0.1/api/events"),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get("content-type")).toContain(
|
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
||||||
"text/event-stream",
|
|
||||||
);
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
expect(text).toContain('event: status\ndata: {"tick":0}\n\n');
|
expect(text).toContain('event: status\ndata: {"tick":0}\n\n');
|
||||||
expect(text).toContain('event: status\ndata: {"tick":2}\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.
|
// SyncEngine semantics: fingerprint skip, single-flight coalescing, status feed.
|
||||||
import { describe, expect, test } from "bun:test";
|
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 {
|
import {
|
||||||
type LastSync,
|
type LastSync,
|
||||||
makeSyncEngine,
|
makeSyncEngine,
|
||||||
@@ -19,33 +19,31 @@ const harness = (opts?: {
|
|||||||
const applied = yield* Ref.make(0);
|
const applied = yield* Ref.make(0);
|
||||||
const last = yield* Ref.make<LastSync | undefined>(undefined);
|
const last = yield* Ref.make<LastSync | undefined>(undefined);
|
||||||
const entries = opts?.entries ?? (() => ["a", "b"]);
|
const entries = opts?.entries ?? (() => ["a", "b"]);
|
||||||
const engine = yield* makeSyncEngine<Report, ReadonlyArray<string>, never>(
|
const engine = yield* makeSyncEngine<Report, ReadonlyArray<string>, never>({
|
||||||
{
|
compute: () =>
|
||||||
compute: () =>
|
Effect.suspend(() => {
|
||||||
Effect.suspend(() => {
|
const e = entries();
|
||||||
const e = entries();
|
return Effect.succeed({
|
||||||
return Effect.succeed({
|
entries: e,
|
||||||
entries: e,
|
report: { included: e.length },
|
||||||
report: { included: e.length },
|
});
|
||||||
});
|
}).pipe(
|
||||||
}).pipe(
|
opts?.computeDelayMs
|
||||||
opts?.computeDelayMs
|
? Effect.delay(Duration.millis(opts.computeDelayMs))
|
||||||
? Effect.delay(Duration.millis(opts.computeDelayMs))
|
: (x) => x,
|
||||||
: (x) => x,
|
),
|
||||||
),
|
apply: () => Ref.update(applied, (n) => n + 1),
|
||||||
apply: () => Ref.update(applied, (n) => n + 1),
|
lastSync: {
|
||||||
lastSync: {
|
get: Ref.get(last),
|
||||||
get: Ref.get(last),
|
set: (l) => Ref.set(last, l),
|
||||||
set: (l) => Ref.set(last, l),
|
|
||||||
},
|
|
||||||
settings: Effect.succeed({
|
|
||||||
pollInterval: Duration.minutes(60),
|
|
||||||
watch: false,
|
|
||||||
debounce: Duration.millis(10),
|
|
||||||
watchDirs: [],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
settings: Effect.succeed({
|
||||||
|
pollInterval: Duration.minutes(60),
|
||||||
|
watch: false,
|
||||||
|
debounce: Duration.millis(10),
|
||||||
|
watchDirs: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
return { engine, applied, last };
|
return { engine, applied, last };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user