Files
punktfunk/plugin-kit/src/cli.ts
T
enricobuehler 10a0ef3283 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.
2026-08-08 02:19:06 +02:00

91 lines
3.6 KiB
TypeScript

// Minimal plugin CLI scaffold. Deliberately NOT `effect/unstable/cli`: its runner needs
// Stdio/Terminal/FileSystem service implementations that only ship in platform packages,
// which would add a runtime dependency to every plugin for what is a five-verb ops tool.
// A plugin CLI is `<bin> <command> [args...]` — this dispatcher gives that shape the same
// 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 { loggingLayer } from "./logging.js";
import type { PluginKitDef } from "./runtime.js";
export interface CliCommand<R> {
readonly summary: string;
/** Set when the command works without a running host (scan/preview style). */
readonly offline?: boolean;
readonly run: (
argv: ReadonlyArray<string>,
) => Effect.Effect<void, unknown, R | HostClient | PluginInfo>;
}
/** A HostClient whose calls fail — the offline lane for host-free commands. */
const offlineFacade = (name: string): Punktfunk =>
({
request: async (method: string, path: string) => {
throw new Error(
`${name}: this command ran offline but tried ${method} ${path} — is the host running?`,
);
},
close: () => {},
}) as unknown as Punktfunk;
const usage = <R>(
def: { name: string; version?: string },
commands: Record<string, CliCommand<R>>,
): string => {
const rows = Object.entries(commands)
.map(([cmd, c]) => ` ${cmd.padEnd(12)} ${c.summary}`)
.join("\n");
return `${def.name}${def.version ? ` ${def.version}` : ""}\n\nUsage: punktfunk-plugin-${def.name} <command> [args...]\n\nCommands:\n${rows}\n`;
};
/**
* Run one CLI invocation: dispatch `process.argv[2]`, build the plugin's layer graph,
* run the command, tear down. Exits the process (0 ok / 1 failure / 2 usage).
*/
export const runPluginCli = async <E, R>(opts: {
readonly def: PluginKitDef<E, R>;
readonly commands: Record<string, CliCommand<R>>;
readonly argv?: ReadonlyArray<string>;
}): Promise<void> => {
const argv = opts.argv ?? process.argv.slice(2);
const [name, ...rest] = argv;
const command = name ? opts.commands[name] : undefined;
if (!command) {
console.log(usage(opts.def, opts.commands));
process.exit(name === undefined || name === "help" ? 0 : 2);
}
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 }),
loggingLayer(opts.def.name),
);
const rt = ManagedRuntime.make(Layer.provideMerge(opts.def.layer, base));
try {
await rt.runPromise(Effect.scoped(command.run(rest)));
// Do NOT clobber a non-zero code the command set deliberately. `parity --compare` reports a
// mismatch by setting `process.exitCode = 1` and then RETURNING normally — a red parity is a
// finished comparison, not a crashed command. Assigning 0 here unconditionally overwrote it,
// so the one verb documented as a release gate ("exits non-zero on any difference", "do not
// publish a version whose parity run is red") always exited 0, and any scripted use of it
// passed. MEASURED against a live host on 2026-08-06: `parity FAILED — 1 missing`, exit 0.
process.exitCode ??= 0;
} catch (e) {
const hint =
e instanceof HostRequestError ? " (is the Punktfunk host running?)" : "";
console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`);
process.exitCode = 1;
} finally {
await rt.dispose();
pf.close();
}
};