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.
136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
// Schema-driven plugin config with raw round-trip semantics.
|
|
//
|
|
// The invariant that kills the raw-vs-resolved muddle of the first-generation plugins:
|
|
// the file on disk is ALWAYS the operator-authored (raw / Encoded) shape; defaults live
|
|
// ONLY in the Schema (`Schema.withDecodingDefaultKey(..., { encodingStrategy: "omit" })`)
|
|
// and are applied at decode time. `saveRaw` validates by decoding but persists the raw
|
|
// shape verbatim — a UI save never bakes defaults into the file.
|
|
//
|
|
// Ported semantics from rom-manager's state.ts: state dir 0700, atomic temp+rename 0600
|
|
// writes, POSIX group/world-writable refusal (config controls commands run as the host
|
|
// user), missing file == empty config.
|
|
import * as fs from "node:fs";
|
|
import { Effect, PubSub, Schema, Stream } from "effect";
|
|
import {
|
|
ConfigParseError,
|
|
ConfigPermissionError,
|
|
type ConfigWriteError,
|
|
} from "./errors.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. */
|
|
readonly load: Effect.Effect<
|
|
S["Type"],
|
|
ConfigParseError | ConfigPermissionError
|
|
>;
|
|
/** The operator-authored shape, validated but NOT defaulted — what the UI edits. */
|
|
readonly loadRaw: Effect.Effect<
|
|
S["Encoded"],
|
|
ConfigParseError | ConfigPermissionError
|
|
>;
|
|
/**
|
|
* Validate-by-decode, persist the RAW shape verbatim (atomic), emit the decoded
|
|
* config on `changes`, and return it.
|
|
*/
|
|
readonly saveRaw: (
|
|
raw: unknown,
|
|
) => 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). */
|
|
readonly path: string;
|
|
}
|
|
|
|
/** Refuse a group/world-writable config file (POSIX only; Windows state dir is DACL'd). */
|
|
const checkNotWorldWritable = (
|
|
file: string,
|
|
): Effect.Effect<void, ConfigPermissionError> =>
|
|
Effect.suspend(() => {
|
|
if (process.platform === "win32") return Effect.void;
|
|
let mode: number;
|
|
try {
|
|
mode = fs.statSync(file).mode;
|
|
} catch {
|
|
return Effect.void; // absent — nothing to guard
|
|
}
|
|
return (mode & 0o022) !== 0
|
|
? Effect.fail(new ConfigPermissionError({ path: file, mode }))
|
|
: Effect.void;
|
|
});
|
|
|
|
const readRawObject = (
|
|
file: string,
|
|
): Effect.Effect<unknown, ConfigParseError | ConfigPermissionError> =>
|
|
checkNotWorldWritable(file).pipe(
|
|
Effect.flatMap(() =>
|
|
Effect.suspend(() => {
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
return Effect.succeed({} as unknown); // missing file == empty config
|
|
}
|
|
try {
|
|
return Effect.succeed(JSON.parse(text) as unknown);
|
|
} catch (e) {
|
|
return Effect.fail(
|
|
new ConfigParseError({ path: file, issue: String(e) }),
|
|
);
|
|
}
|
|
}),
|
|
),
|
|
);
|
|
|
|
export const makeConfigService = <S extends Schema.Top>(opts: {
|
|
readonly schema: S;
|
|
readonly fileName?: string;
|
|
}): Effect.Effect<ConfigService<S>, never, PluginInfo> =>
|
|
Effect.gen(function* () {
|
|
const info = yield* PluginInfo;
|
|
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> =>
|
|
Schema.decodeUnknownEffect(opts.schema)(raw).pipe(
|
|
Effect.mapError(
|
|
(e) => new ConfigParseError({ path: file, issue: String(e) }),
|
|
),
|
|
) as Effect.Effect<S["Type"], ConfigParseError>;
|
|
|
|
const load = readRawObject(file).pipe(Effect.flatMap(decode));
|
|
|
|
// Validated (a broken file must not masquerade as authored config), returned verbatim.
|
|
const loadRaw = readRawObject(file).pipe(
|
|
Effect.tap(decode),
|
|
Effect.map((raw) => raw as S["Encoded"]),
|
|
);
|
|
|
|
const saveRaw = (raw: unknown) =>
|
|
decode(raw).pipe(
|
|
Effect.tap(() => ensureStateDir(info.name)),
|
|
Effect.tap(() =>
|
|
atomicWriteFile(file, `${JSON.stringify(raw, null, 2)}\n`),
|
|
),
|
|
Effect.tap((decoded) => PubSub.publish(hub, decoded)),
|
|
);
|
|
|
|
return {
|
|
load,
|
|
loadRaw,
|
|
saveRaw,
|
|
changes: Stream.fromPubSub(hub),
|
|
path: file,
|
|
} satisfies ConfigService<S>;
|
|
});
|
|
|
|
// A `Context.Service` class factory is deliberately NOT provided — plugins define their
|
|
// own service key over `ConfigService<their schema>` so the config type stays precise:
|
|
//
|
|
// class RomConfig extends Context.Service<RomConfig, ConfigService<typeof RomConfigSchema>>()(
|
|
// "rom-manager/Config",
|
|
// ) {
|
|
// static layer = Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema }))
|
|
// }
|