ci / docs-site (push) Successful in 2m8s
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 4m21s
apple / swift (push) Successful in 4m52s
android / android (push) Successful in 5m22s
deb / build-publish (push) Successful in 3m57s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 3m55s
ci / rust (push) Successful in 9m19s
arch / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 14m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 20m44s
Installing any plugin from the console failed on a user's Fedora 44 host with
plugin store job failed: @punktfunk/plugin-virtualhere is not present after install
while the same version installed fine on every box we tried. The difference turned
out to be a file in his home directory.
`bun add` does not install into its working directory. It walks UP to the nearest
ancestor `package.json` and installs into that tree. A fresh plugins dir has no
`package.json` — the store and the runner only write `bunfig.toml` — so any stray
one above it captures the install: the packages land in `~/node_modules`, the
dependency is written to `~/package.json`, bun prints "installed …" and exits 0,
and the plugins dir stays empty. The plugins-dir `bunfig.toml` is not read in that
case either, so the outcome splits on whether `@punktfunk` resolves in the hijacked
tree: with no scope mapping there it 404s against npmjs and exits 1 ("the plugin
runner exited with status 1"); with one, it succeeds into the wrong tree and the
job dies on the presence check instead. Only the second shape looks like a broken
store, which is why this took a reproduction to find.
Seed the plugins dir with a `package.json` so it owns bun's install root. Three
call sites rather than one: the store job, the `plugins add` CLI, and the SDK's
`ensurePluginsDir`. The runner ships as its own package and can predate the host
binary, so the host cannot delegate this — the same reason `ensure_bunfig_scope`
writes the registry mapping on this side rather than passing a runner flag.
Seeding only ever touches a tree with no `node_modules`. A dir with packages and
no `package.json` is the hand-assembled/older layout that `installed_packages`
deliberately falls back to the naming convention for, and dropping an empty
`dependencies` on it reports every plugin the operator runs as uninstalled —
already pinned by `an_emptied_dependency_list_means_nothing_is_installed`.
The residual failure stops dead-ending: if the presence check fails anyway, the
error names the capturing ancestor and what to do about it.
Reproduced and fixed on glass (Nobara 44 VM, host + runner 0.22.3): with a stray
~/package.json the store reproduces the field report byte for byte — same 150 ms,
same phase sequence, same message — and with the plugins dir seeded the same
install lands correctly with nothing leaking into the home dir.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
// `punktfunk-host plugins …` package operations, run on the vendored bun. The host CLI forwards
|
|
// add/remove/list here (crates/punktfunk-host/src/plugins.rs) and the runner-cli exposes them as
|
|
// subcommands. Everything a plugin needs to be installed — the plugins dir, the `@punktfunk`
|
|
// registry scope in bunfig.toml, and the right bun — is handled here so the operator types one line
|
|
// instead of the old create-dir / write-bunfig / `bun add` ritual.
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { configDir } from "./config.js";
|
|
|
|
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
|
|
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
|
|
|
|
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
|
|
export const pluginsDirDefault = (): string => path.join(configDir(), "plugins");
|
|
|
|
export interface ResolveOptions {
|
|
/**
|
|
* Allow names that resolve on the PUBLIC npm registry (unscoped `punktfunk-plugin-*`, foreign
|
|
* scopes, arbitrary paths). Off by default: only the `@punktfunk` scope — pinned to the Gitea
|
|
* registry by [`ensureBunfig`] — installs without it, so a typo or a squatted look-alike
|
|
* package can't silently pull operator-privileged code from npmjs.org (the CLI flag is
|
|
* `--allow-public-registry`).
|
|
*/
|
|
allowPublicRegistry?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve a friendly plugin name to its npm package. A bare first-party name maps into the
|
|
* `@punktfunk` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` →
|
|
* `@punktfunk/plugin-rom-manager`); an `@punktfunk/…` name is used verbatim. Anything else —
|
|
* the unscoped `punktfunk-plugin-…` convention, foreign scopes, registry paths — resolves on
|
|
* the public registry and is refused unless [`ResolveOptions.allowPublicRegistry`] is set.
|
|
*/
|
|
export const resolvePackage = (
|
|
name: string,
|
|
opts: ResolveOptions = {},
|
|
): string => {
|
|
const n = name.trim();
|
|
if (!n) throw new Error("empty plugin name");
|
|
if (!n.startsWith("@") && !n.includes("/") && !n.startsWith("punktfunk-plugin-")) {
|
|
return `@punktfunk/plugin-${n}`; // bare first-party name
|
|
}
|
|
if (n.startsWith("@punktfunk/")) return n; // first-party scope, pinned to our registry
|
|
if (!opts.allowPublicRegistry) {
|
|
throw new Error(
|
|
`'${n}' would install from the PUBLIC npm registry, not Punktfunk's. Plugins run ` +
|
|
"with operator privileges - install only code you trust. If you mean it, re-run " +
|
|
"with --allow-public-registry.",
|
|
);
|
|
}
|
|
return n;
|
|
};
|
|
|
|
/** Does this resolved package name install from Punktfunk's own (Gitea) registry? */
|
|
const isFirstParty = (pkg: string): boolean => pkg.startsWith("@punktfunk/");
|
|
|
|
/**
|
|
* Create the plugins dir (and parents) if needed, and make it bun's install ROOT. On Windows the
|
|
* ACL lockdown is the host's job.
|
|
*
|
|
* The `package.json` is load-bearing, not decoration: `bun add` installs into the nearest ancestor
|
|
* `package.json`, not into its working directory. Without one here, a stray `~/package.json` — one
|
|
* old `bun add`/`npm init` in a home dir — silently captures every plugin install. bun reports
|
|
* success and exits 0, the packages land in that tree, and the plugins dir stays empty (reproduced
|
|
* on-glass 2026-07-31; it presented as a plugin store that installs nothing).
|
|
*
|
|
* Only seeds a tree with no `node_modules`. A dir with packages but no `package.json` is
|
|
* hand-assembled or an older layout, and both this module's [`listInstalled`] and the host's
|
|
* installed-package scan fall back to the naming convention there; an empty `dependencies` would
|
|
* make the host report every plugin already installed as gone.
|
|
*/
|
|
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const manifest = path.join(dir, "package.json");
|
|
if (!fs.existsSync(manifest) && !fs.existsSync(path.join(dir, "node_modules"))) {
|
|
fs.writeFileSync(manifest, '{\n "name": "punktfunk-plugins",\n "private": true\n}\n');
|
|
}
|
|
return dir;
|
|
};
|
|
|
|
/**
|
|
* 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(),
|
|
extraScopes: Record<string, string> = {},
|
|
): void => {
|
|
const file = path.join(dir, "bunfig.toml");
|
|
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
|
|
}
|
|
|
|
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,
|
|
out.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${block}`),
|
|
);
|
|
} else {
|
|
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. */
|
|
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, 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
|
|
// traverse — imports die with EPERM even though the plugins-dir DACL grants read (seen live
|
|
// on-glass). copyfile keeps the plugins tree self-contained under %ProgramData%.
|
|
if (action === "add" && process.platform === "win32") {
|
|
args.push("--backend=copyfile");
|
|
}
|
|
const res = Bun.spawnSync(args, {
|
|
cwd: dir,
|
|
stdio: ["inherit", "inherit", "inherit"],
|
|
});
|
|
if (!res.success) {
|
|
throw new Error(`bun ${action} exited ${res.exitCode ?? "?"} — see output above`);
|
|
}
|
|
};
|
|
|
|
/** Install one or more plugins by friendly name or package. */
|
|
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
|
|
const pkgs = names.map((n) => resolvePackage(n, opts));
|
|
const log = opts.log ?? ((l: string) => console.log(l));
|
|
for (const pkg of pkgs.filter((p) => !isFirstParty(p))) {
|
|
log(
|
|
`[plugins] WARNING: ${pkg} installs from the public npm registry - it is not ` +
|
|
"published by Punktfunk. It will run with operator privileges.",
|
|
);
|
|
}
|
|
runBun("add", pkgs, opts);
|
|
};
|
|
|
|
/** Uninstall one or more plugins by friendly name or package. Removal is always safe — a name
|
|
* never gates on the registry it once came from. */
|
|
export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
|
|
runBun(
|
|
"remove",
|
|
names.map((n) => resolvePackage(n, { allowPublicRegistry: true })),
|
|
opts,
|
|
);
|
|
|
|
export interface InstalledPlugin {
|
|
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
|
|
pkg: string;
|
|
/** Installed version from the package's package.json, if readable. */
|
|
version?: string;
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
const out: InstalledPlugin[] = [];
|
|
const versionOf = (pkgDir: string): string | undefined => {
|
|
try {
|
|
const m = JSON.parse(
|
|
fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"),
|
|
) as { version?: string };
|
|
return m.version;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(modules).sort();
|
|
} catch {
|
|
return out; // no plugins installed yet
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.startsWith("punktfunk-plugin-")) {
|
|
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
|
|
} else if (entry.startsWith("@")) {
|
|
let scoped: string[] = [];
|
|
try {
|
|
scoped = fs.readdirSync(path.join(modules, entry)).sort();
|
|
} catch {
|
|
scoped = [];
|
|
}
|
|
for (const s of scoped) {
|
|
if (s.startsWith("plugin-")) {
|
|
out.push({
|
|
pkg: `${entry}/${s}`,
|
|
version: versionOf(path.join(modules, entry, s)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
};
|