Files
punktfunk/plugin-kit/src/cli.ts
T
enricobuehler 4bcb3794c5
nix / flake (pull_request) Failing after 1s
ci / bun-nix (pull_request) Successful in 22s
ci / web (pull_request) Successful in 58s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust-arm64 (pull_request) Successful in 2m2s
ci / rust (pull_request) Successful in 6m15s
fix(plugin-kit): the SQLite reader never opened anything on Linux
Found while running the lutris plugin's own release gate against a live host on
.21: `parity --compare` reported `1 missing` — the plugin produced NO entry for
the one game Lutris had, while the host's built-in scanner produced it fine.
`scan` agreed: "present: 0 games". `detect` still said "present", because detect
only stats the file.

`openReadOnly` builds a `file:<path>?immutable=1` URI — the right idea, since
`immutable=1` is what makes this a pure read that cannot lock a running
launcher's database or spawn WAL sidecars next to it. But it opened that name
with `{ readonly: true }`, and the options object does NOT enable SQLite's URI
filename parsing. Without SQLITE_OPEN_URI the name is taken literally, no such
file exists, and the open throws:

    SQLiteError: unable to open database file

Every path here then degrades that to silence by design: `openReadOnly` returns
`undefined` for "this launcher isn't installed", `withReadOnlyDb` passes the
`undefined` through, and callers write `withReadOnlyDb(...) ?? []`. So a total,
permanent failure to read ANY database was indistinguishable from an empty
library. The lutris plugin reported 0 games on every Linux box, always.

It is platform-split, which is why it survived review and local runs: macOS
links Apple's system SQLite, which is built with URI filenames enabled, so the
same call succeeds there. Linux uses Bun's bundled SQLite, which is not. MEASURED
both ways on bun 1.3.14.

Fixed by passing the flags explicitly — SQLITE_OPEN_READONLY | SQLITE_OPEN_URI.
(`{ readonly: true, uri: true }` is not a supported option shape; also measured.)

And the second half, which is why nothing caught it: `parity --compare` sets
`process.exitCode = 1` on a mismatch and then returns NORMALLY — a red parity is
a finished comparison, not a crashed command. `runPluginCli` then assigned
`process.exitCode = 0` unconditionally after the effect resolved, overwriting it.
So the one verb both plugin READMEs document as the 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. Now `??= 0`, so a code a
command set deliberately survives.

Tests: the sqlite helper had NO coverage at all, which is the whole reason a
total failure shipped looking like an empty library. Added five cases against a
REAL database file — reads rows back, handles a path needing URI escaping,
withReadOnlyDb round-trips, an absent file is `undefined` not a throw, and a bad
query still degrades to []. Verified they actually catch it: against the shipped
code on Linux, 4 of the 5 fail; with the fix, 21/21 in that file and 61/61 across
the suite, typecheck and build clean.

End to end on .21 with the fix: lutris `scan` goes 0 -> 1 games and
`parity --compare` reports "parity OK — 1 entries identical". A deliberately
doctored baseline now exits 1 instead of 0.

0.3.0 -> 0.3.1.
2026-08-06 20:14:04 +02:00

95 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 {
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";
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();
}
};