// `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: `/plugins` (matches runner.ts discovery). */ export const pluginsDirDefault = (): string => path.join(configDir(), "plugins"); /** * 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`); a scoped name (`@…/…`), the unscoped plugin convention * (`punktfunk-plugin-…`), or any name with a `/` is used verbatim. */ export const resolvePackage = (name: string): string => { const n = name.trim(); if (!n) throw new Error("empty plugin name"); if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim if (n.includes("/")) return n; // some other registry path — trust it return `@punktfunk/plugin-${n}`; // bare first-party name }; /** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */ export const ensurePluginsDir = (dir = pluginsDirDefault()): string => { fs.mkdirSync(dir, { recursive: true }); return dir; }; /** * Ensure `/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. */ export const ensureBunfig = (dir = pluginsDirDefault()): void => { const file = path.join(dir, "bunfig.toml"); const scopeLine = `"@punktfunk" = "${REGISTRY}"`; 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. fs.writeFileSync( file, existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`), ); } else { const sep = existing.endsWith("\n") ? "" : "\n"; fs.writeFileSync(file, `${existing}${sep}\n${table}`); } }; export interface PkgOpts { /** Plugins dir. Default `/plugins`. */ dir?: string; /** Line sink for progress. Default stdout. */ log?: (line: string) => void; } /** 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); 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 res = Bun.spawnSync([process.execPath, action, ...pkgs], { 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 => runBun("add", names.map(resolvePackage), opts); /** Uninstall one or more plugins by friendly name or package. */ export const removePlugins = (names: string[], opts: PkgOpts = {}): void => runBun("remove", names.map(resolvePackage), 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 `/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. */ 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 === "@punktfunk") { 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; };