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>
This commit is contained in:
+64
-20
@@ -61,42 +61,75 @@ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure `<dir>/bunfig.toml` points the `@punktfunk` scope at the registry so `bun add` resolves
|
||||
* first-party plugins. Idempotent: a file already mapping the scope is left untouched; an existing
|
||||
* bunfig with an `[install.scopes]` table gets our line inserted under it; anything else appends a
|
||||
* fresh table.
|
||||
* Ensure `<dir>/bunfig.toml` maps every scope we need to its registry, so `bun add` resolves
|
||||
* plugins from the right place. `@punktfunk` → Punktfunk's own registry is always mapped;
|
||||
* `extraScopes` adds others — a plugin-store catalog entry carries its own registry, and the scope
|
||||
* is what binds a package name to it (design D8, which is why catalog entries must be scoped).
|
||||
*
|
||||
* Idempotent and non-destructive: a scope already mapped to the same URL is left alone, a scope
|
||||
* mapped to a *different* URL is rewritten, and any unrelated bunfig content is preserved.
|
||||
*/
|
||||
export const ensureBunfig = (dir = pluginsDirDefault()): void => {
|
||||
export const ensureBunfig = (
|
||||
dir = pluginsDirDefault(),
|
||||
extraScopes: Record<string, string> = {},
|
||||
): void => {
|
||||
const file = path.join(dir, "bunfig.toml");
|
||||
const scopeLine = `"@punktfunk" = "${REGISTRY}"`;
|
||||
const wanted: Record<string, string> = { "@punktfunk": REGISTRY, ...extraScopes };
|
||||
let existing = "";
|
||||
try {
|
||||
existing = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
// no bunfig yet — write a fresh one below
|
||||
}
|
||||
if (existing.includes("@punktfunk") && existing.includes(REGISTRY)) return; // already wired
|
||||
|
||||
const table = `[install.scopes]\n${scopeLine}\n`;
|
||||
if (!existing.trim()) {
|
||||
fs.writeFileSync(file, table);
|
||||
} else if (/^\[install\.scopes\][^\n]*$/m.test(existing)) {
|
||||
// Insert our scope line right after the existing table header.
|
||||
let out = existing;
|
||||
const missing: string[] = [];
|
||||
for (const [scope, url] of Object.entries(wanted)) {
|
||||
// Match `"@scope" = "…"` (quoted or bare key) anywhere in the file.
|
||||
const line = new RegExp(`^\\s*"?${escapeRe(scope)}"?\\s*=\\s*".*"\\s*$`, "m");
|
||||
const replacement = `"${scope}" = "${url}"`;
|
||||
if (line.test(out)) {
|
||||
const current = out.match(line)?.[0] ?? "";
|
||||
if (current.includes(`"${url}"`)) continue; // already correct
|
||||
out = out.replace(line, replacement);
|
||||
} else {
|
||||
missing.push(replacement);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) {
|
||||
if (out !== existing) fs.writeFileSync(file, out);
|
||||
return;
|
||||
}
|
||||
const block = missing.join("\n");
|
||||
if (!out.trim()) {
|
||||
fs.writeFileSync(file, `[install.scopes]\n${block}\n`);
|
||||
} else if (/^\[install\.scopes\][^\n]*$/m.test(out)) {
|
||||
// Insert under the existing table header.
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`),
|
||||
out.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${block}`),
|
||||
);
|
||||
} else {
|
||||
const sep = existing.endsWith("\n") ? "" : "\n";
|
||||
fs.writeFileSync(file, `${existing}${sep}\n${table}`);
|
||||
const sep = out.endsWith("\n") ? "" : "\n";
|
||||
fs.writeFileSync(file, `${out}${sep}\n[install.scopes]\n${block}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export interface PkgOpts extends ResolveOptions {
|
||||
/** Plugins dir. Default `<config_dir>/plugins`. */
|
||||
dir?: string;
|
||||
/** Line sink for progress. Default stdout. */
|
||||
log?: (line: string) => void;
|
||||
/**
|
||||
* Record the resolved version exactly (`bun add --exact`) instead of a caret range. The plugin
|
||||
* store always sets this: a catalog entry pins one reviewed version, and a caret range in
|
||||
* `package.json` would let a later `bun install` in this tree drift off it.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/** Extra `scope → registry URL` mappings to write into `bunfig.toml` before installing. */
|
||||
registries?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */
|
||||
@@ -104,11 +137,21 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
|
||||
const dir = opts.dir ?? pluginsDirDefault();
|
||||
const log = opts.log ?? ((l: string) => console.log(l));
|
||||
ensurePluginsDir(dir);
|
||||
if (action === "add") ensureBunfig(dir);
|
||||
if (action === "add") ensureBunfig(dir, opts.registries);
|
||||
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
|
||||
// `process.execPath` is the bun running this file (the vendored one under the package), so a
|
||||
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
|
||||
const args = [process.execPath, action, ...pkgs];
|
||||
if (action === "add") {
|
||||
// NEVER run install lifecycle scripts. A plugin is code we chose to run under the runner,
|
||||
// where it is supervised and (on Windows) de-privileged; a postinstall script runs
|
||||
// immediately, as whoever is installing — which on a console-triggered install is the host
|
||||
// service. bun already declines untrusted scripts by default; this makes it explicit and
|
||||
// unconditional. A plugin that needs a native build step is a review rejection, not a case
|
||||
// to support.
|
||||
args.push("--ignore-scripts");
|
||||
if (opts.exact) args.push("--exact");
|
||||
}
|
||||
// Windows: install file COPIES, never bun's default hardlinks. A hardlinked file's canonical
|
||||
// path resolves into the installing admin's per-user bun cache
|
||||
// (C:\Users\<admin>\.bun\install\cache\…), which the de-privileged LocalService runner cannot
|
||||
@@ -156,9 +199,10 @@ export interface InstalledPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate installed plugin packages under `<dir>/node_modules` — both the scoped first-party
|
||||
* convention (`@punktfunk/plugin-*`) and the unscoped one (`punktfunk-plugin-*`). Mirrors the
|
||||
* discovery in runner.ts so `list` shows exactly what the runner would supervise.
|
||||
* Enumerate installed plugin packages under `<dir>/node_modules` — the unscoped convention
|
||||
* (`punktfunk-plugin-*`) and **any** scope's `plugin-*` (`@punktfunk/plugin-rom-manager`,
|
||||
* `@retro-hub/plugin-x`). Mirrors the discovery in runner.ts so `list` shows exactly what the
|
||||
* runner would supervise.
|
||||
*/
|
||||
export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
|
||||
const modules = path.join(dir, "node_modules");
|
||||
@@ -182,7 +226,7 @@ export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("punktfunk-plugin-")) {
|
||||
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
|
||||
} else if (entry === "@punktfunk") {
|
||||
} else if (entry.startsWith("@")) {
|
||||
let scoped: string[] = [];
|
||||
try {
|
||||
scoped = fs.readdirSync(path.join(modules, entry)).sort();
|
||||
|
||||
+48
-1
@@ -16,6 +16,11 @@
|
||||
//
|
||||
// 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";
|
||||
@@ -25,18 +30,56 @@ const arg = (flag: string): string | undefined => {
|
||||
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 (a === "--plugins" || a === "--scripts") {
|
||||
if (VALUE_FLAGS.includes(a)) {
|
||||
i++; // skip its value too
|
||||
continue;
|
||||
}
|
||||
@@ -51,6 +94,10 @@ const pkgOpts = {
|
||||
// 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 = (
|
||||
|
||||
+10
-5
@@ -323,10 +323,15 @@ export const discoverUnits = (
|
||||
addPlugin(path.join(modules, pkg), pkg);
|
||||
continue;
|
||||
}
|
||||
// Scoped convention: `@punktfunk/plugin-*` (first-party). A scoped name resolves cleanly
|
||||
// from a single registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect`
|
||||
// as shared (hoisted) deps rather than bundling its own copy of each.
|
||||
if (pkg === "@punktfunk") {
|
||||
// Scoped convention: `<any scope>/plugin-*`. A scoped name resolves cleanly from a
|
||||
// registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` as shared
|
||||
// (hoisted) deps rather than bundling its own copy of each.
|
||||
//
|
||||
// ANY scope, not just `@punktfunk`: the plugin store requires catalog entries to be
|
||||
// scoped precisely so the scope can map to that entry's registry, so a third-party
|
||||
// plugin necessarily arrives as `@their-scope/plugin-*`. Limiting discovery to the
|
||||
// first-party scope would let such a plugin install and then never run.
|
||||
if (pkg.startsWith("@")) {
|
||||
try {
|
||||
for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) {
|
||||
if (scoped.startsWith("plugin-")) {
|
||||
@@ -334,7 +339,7 @@ export const discoverUnits = (
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// no @punktfunk scope dir — fine
|
||||
// not a readable scope dir — fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,55 @@ describe("listInstalled", () => {
|
||||
{ pkg: "punktfunk-plugin-broken", version: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds plugins in ANY scope, not just @punktfunk", () => {
|
||||
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
|
||||
// registry, so a third-party plugin necessarily arrives as `@their-scope/plugin-*`.
|
||||
// Discovery limited to @punktfunk would let it install and then never run.
|
||||
const dir = tmp("list-foreign-scope");
|
||||
writePkg(dir, path.join("@retro-hub", "plugin-x"), "1.0.0");
|
||||
writePkg(dir, path.join("@retro-hub", "helper"), "1.0.0"); // scoped non-plugin — ignored
|
||||
expect(listInstalled(dir)).toEqual([{ pkg: "@retro-hub/plugin-x", version: "1.0.0" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBunfig with extra scopes", () => {
|
||||
const read = (dir: string) => fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
|
||||
|
||||
test("maps a third-party scope alongside @punktfunk", () => {
|
||||
const dir = tmp("bunfig-extra");
|
||||
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
|
||||
const out = read(dir);
|
||||
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
|
||||
});
|
||||
|
||||
test("is idempotent and rewrites a scope whose registry changed", () => {
|
||||
const dir = tmp("bunfig-rewrite");
|
||||
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
|
||||
ensureBunfig(dir, { "@retro-hub": "https://old.example/npm/" });
|
||||
expect(read(dir).match(/@retro-hub/g)?.length).toBe(1);
|
||||
|
||||
ensureBunfig(dir, { "@retro-hub": "https://new.example/npm/" });
|
||||
const out = read(dir);
|
||||
expect(out).toContain('"@retro-hub" = "https://new.example/npm/"');
|
||||
expect(out).not.toContain("old.example");
|
||||
// the first-party mapping survives an unrelated scope edit
|
||||
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
});
|
||||
|
||||
test("preserves unrelated scopes already in the file", () => {
|
||||
const dir = tmp("bunfig-preserve");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bunfig.toml"),
|
||||
'[install.scopes]\n"@acme" = "https://acme.example/"\n',
|
||||
);
|
||||
ensureBunfig(dir, { "@retro-hub": "https://retro.example/npm/" });
|
||||
const out = read(dir);
|
||||
expect(out).toContain('"@acme" = "https://acme.example/"');
|
||||
expect(out).toContain('"@retro-hub" = "https://retro.example/npm/"');
|
||||
expect(out).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePluginsDir", () => {
|
||||
|
||||
Reference in New Issue
Block a user