Files
punktfunk/sdk/src/runner-cli.ts
T
enricobuehlerandClaude Fable 5 45c3b96907 feat(store): plugin store host module — signed catalogs, tiered trust, install jobs
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.

- store/index.rs   signed index parse + ed25519 verify (ring), validate-and-drop
                   per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
                   rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
                   parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs    single-flight install/uninstall: registry-integrity preflight
                   against the pin, spawn the runner CLI with live log capture,
                   post-install version check with rollback, provenance record,
                   runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs    12 routes under /api/v1/store, denied to the plugin token
                   (a plugin that can install plugins is an escalation primitive)

Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00

164 lines
5.8 KiB
TypeScript

#!/usr/bin/env bun
// `punktfunk-scripting` — the plugin/script runner AND the `punktfunk-host plugins …` package ops.
//
// With NO subcommand it RUNS the runner: discover the operator's scripts + punktfunk-plugin-*
// packages and supervise them (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree
// structurally, so every plugin's finalizers run before exit (the systemd-stop story). This bare
// form is what the systemd unit / Windows scheduled task launch — do not change its behavior.
//
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
// here):
// add <name…> install first-party plugins (playnite, rom-manager); anything resolving on
// the PUBLIC npm registry (punktfunk-plugin-*, foreign scopes) additionally
// needs --allow-public-registry
// remove <name…> uninstall
// list list installed plugin packages
//
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner)
// bun src/runner-cli.ts add playnite [--plugins DIR] (package ops)
//
// Package-op flags: --exact pins the resolved version instead of a caret range, and
// --registry @scope=https://… maps a scope to its registry in bunfig.toml. Both exist for the
// plugin store (crates/punktfunk-host/src/store), which installs one reviewed version of a
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
import { Effect, Fiber } from "effect";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import { discoverUnits, runner } from "./runner.js";
const arg = (flag: string): string | undefined => {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
};
/** Every value of a repeatable flag (`--registry a=b --registry c=d`). */
const argAll = (flag: string): string[] => {
const out: string[] = [];
for (let i = 0; i < process.argv.length; i++) {
if (process.argv[i] === flag && process.argv[i + 1] !== undefined) out.push(process.argv[++i]);
}
return out;
};
/** Flags that take a value — skipped (with their value) when collecting positionals. */
const VALUE_FLAGS = ["--plugins", "--scripts", "--registry"];
const options = {
scriptsDir: arg("--scripts"),
pluginsDir: arg("--plugins"),
};
/**
* `--registry @scope=https://registry/` → `{ "@scope": "https://registry/" }`. The plugin store
* passes one per catalog entry so a third-party package's scope resolves to *its* registry rather
* than the public npm default.
*/
const parseRegistries = (): Record<string, string> => {
const out: Record<string, string> = {};
for (const spec of argAll("--registry")) {
const eq = spec.indexOf("=");
if (eq <= 0) {
console.error(`[plugins] ignoring malformed --registry '${spec}' (expected @scope=URL)`);
continue;
}
const scope = spec.slice(0, eq).trim();
const url = spec.slice(eq + 1).trim();
if (!scope.startsWith("@") || !url.startsWith("https://")) {
console.error(
`[plugins] ignoring --registry '${spec}' (scope must start with @, url with https://)`,
);
continue;
}
out[scope] = url;
}
return out;
};
// Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the
// value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter.
const positionals = (): string[] => {
const out: string[] = [];
for (let i = 3; i < process.argv.length; i++) {
const a = process.argv[i];
if (VALUE_FLAGS.includes(a)) {
i++; // skip its value too
continue;
}
if (a.startsWith("-")) continue;
out.push(a);
}
return out;
};
const pkgOpts = {
dir: options.pluginsDir,
// Opt-in for names that resolve on the public npm registry (supply-chain gate in
// plugins.ts::resolvePackage). Boolean flag, so positionals() skips it on its own.
allowPublicRegistry: process.argv.includes("--allow-public-registry"),
// Pin the exact version in package.json instead of a caret range — the plugin store installs
// one reviewed version and must not let a later `bun install` drift off it.
exact: process.argv.includes("--exact"),
registries: parseRegistries(),
};
const runPkgOp = (
op: (names: string[], o: typeof pkgOpts) => void,
verb: string,
): never => {
const names = positionals();
if (names.length === 0) {
console.error(
`usage: punktfunk-host plugins ${verb} <name…> (e.g. playnite, rom-manager)`,
);
process.exit(2);
}
try {
op(names, pkgOpts);
process.exit(0);
} catch (e) {
console.error(`[plugins] ${e instanceof Error ? e.message : e}`);
process.exit(1);
}
};
switch (process.argv[2]) {
case "add":
runPkgOp(addPlugins, "add");
break;
case "remove":
case "rm":
case "uninstall":
runPkgOp(removePlugins, "remove");
break;
case "list":
case "ls": {
const installed = listInstalled(options.pluginsDir);
if (installed.length === 0) {
console.log("No plugins installed.");
} else {
for (const p of installed) {
console.log(p.version ? `${p.pkg}\t${p.version}` : p.pkg);
}
}
process.exit(0);
}
}
// ---- run the runner (default; --list keeps the legacy unit-listing behavior) ------------------
if (process.argv.includes("--list")) {
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
process.exit(0);
}
const fiber = Effect.runFork(runner(options));
let stopping = false;
const shutdown = (signal: string) => {
if (stopping) return process.exit(1); // second signal = get out now
stopping = true;
console.log(`${new Date().toISOString()} [runner] ${signal} — interrupting units…`);
void Effect.runPromise(Fiber.interrupt(fiber)).finally(() => process.exit(0));
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
await Effect.runPromise(Fiber.await(fiber));