feat(plugins): punktfunk-host plugins CLI — add/remove/list/enable/disable/status

One-liner plugin management replacing the manual scripting-dir + bunfig +
bun-add ritual: package ops forward to the bun runner (new sdk plugins
module + runner-cli subcommands, 11 tests green), enable/disable/status
drive the systemd unit on Linux and the PunktfunkScripting scheduled task
on Windows (installer support in the ISS). Docs page rewritten as .mdx
with per-platform Tabs (registered in mdx.tsx).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:34:19 +02:00
parent f250db96f4
commit f0c511c8fa
11 changed files with 1034 additions and 105 deletions
+7
View File
@@ -65,6 +65,7 @@ mod mgmt_token;
mod native;
mod native_pairing;
mod pipeline;
mod plugins;
mod send_pacing;
#[cfg(target_os = "windows")]
#[path = "windows/service.rs"]
@@ -249,6 +250,10 @@ fn real_main() -> Result<()> {
std::process::exit(1);
}
}
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
// PunktfunkScripting scheduled task (Windows). See plugins.rs.
Some("plugins") => plugins::main(&args[1..]),
// Print the management API's OpenAPI document (for client codegen).
Some("openapi") => {
print!("{}", mgmt::openapi_json());
@@ -623,6 +628,8 @@ fn print_usage() {
USAGE:
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
(secure default; add --gamestream for Moonlight compat)
punktfunk-host plugins <CMD> install/run host plugins (add, remove, list, enable,
disable, status) — `plugins --help` for details
punktfunk-host openapi print the management API's OpenAPI document (codegen)
punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane)
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
+389
View File
@@ -0,0 +1,389 @@
//! `punktfunk-host plugins …` — the one-liner plugin CLI.
//!
//! Installing a plugin used to be a hand ritual: create the plugins dir, hand-write a `bunfig.toml`
//! registry scope map, `bun add` the package, then hand-enable a systemd unit (Linux) or a scheduled
//! task (Windows) — all with platform-divergent paths. This subcommand collapses that to
//! `punktfunk-host plugins add playnite` + `punktfunk-host plugins enable`.
//!
//! Split of duties (matching where the machinery already lives):
//! - **Package ops** (`add`/`remove`/`list`) are forwarded to the bun runner (`sdk/src/plugins.ts`),
//! which owns the vendored bun, the `@punktfunk` registry scope, and the plugins dir. We locate the
//! runner rather than reimplementing npm resolution in Rust.
//! - **Service ops** (`enable`/`disable`/`status`) run natively here — `systemctl --user` on Linux,
//! the `PunktfunkScripting` scheduled task on Windows — so they work even without the runner
//! package present.
//!
//! Windows needs elevation for both halves: the plugins dir lives under the ACL'd
//! `%ProgramData%\punktfunk` (see `pf_paths::create_private_dir`) and the task runs as SYSTEM. We
//! check up front and print one actionable line instead of letting `bun add` fail with a bare
//! EACCES.
use anyhow::{bail, Context, Result};
use std::process::Command;
/// The systemd user unit / Windows scheduled task that supervises plugins.
#[cfg(target_os = "linux")]
const UNIT: &str = "punktfunk-scripting";
#[cfg(target_os = "windows")]
const TASK: &str = "PunktfunkScripting";
pub fn main(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("add") | Some("remove") | Some("rm") | Some("uninstall") | Some("list")
| Some("ls") => {
// Package ops write into the (ACL'd, on Windows) plugins dir.
#[cfg(target_os = "windows")]
if !matches!(args.first().map(String::as_str), Some("list") | Some("ls")) {
require_elevation("installing or removing plugins")?;
}
forward_to_runner(args)
}
Some("enable") => {
#[cfg(target_os = "windows")]
require_elevation("enabling the plugin runner")?;
enable()
}
Some("disable") => {
#[cfg(target_os = "windows")]
require_elevation("disabling the plugin runner")?;
disable()
}
Some("status") => status(),
Some("-h") | Some("--help") | Some("help") | None => {
print_usage();
Ok(())
}
Some(other) => bail!("unknown plugins command '{other}' (try `plugins --help`)"),
}
}
fn print_usage() {
eprintln!(
"punktfunk-host plugins — install and run host plugins
USAGE:
punktfunk-host plugins add <name…> install a plugin (playnite, rom-manager, …)
punktfunk-host plugins remove <name…> uninstall a plugin
punktfunk-host plugins list list installed plugins
punktfunk-host plugins enable enable + start the plugin runner (opt-in)
punktfunk-host plugins disable stop + disable the plugin runner
punktfunk-host plugins status is the runner enabled/running?
NAMES:
A bare first-party name resolves into the @punktfunk scope: `playnite` installs
@punktfunk/plugin-playnite, `rom-manager` installs @punktfunk/plugin-rom-manager.
A scoped (@scope/pkg) or `punktfunk-plugin-*` name is used verbatim.
NOTES:
Plugins run under the runner, which is OPT-IN — `plugins add` installs, `plugins enable`
turns the runner on. Plugins are operator-installed code that runs as the host user;
install only plugins you trust.
"
);
#[cfg(target_os = "windows")]
eprintln!(
" On Windows, `add`/`remove`/`enable`/`disable` need an ELEVATED prompt (the plugins\n \
directory and the runner task are admin-owned)."
);
}
// ---- package ops: forward to the bun runner ---------------------------------------------------
/// Locate the runner and hand it the argv verbatim, inheriting stdio so bun's progress output goes
/// straight to the user's terminal. Exits with the runner's own status code.
fn forward_to_runner(args: &[String]) -> Result<()> {
let (program, prefix) = runner_command()?;
let status = Command::new(&program)
.args(&prefix)
.args(args)
.status()
.with_context(|| format!("failed to run the plugin runner ({})", program.display()))?;
if !status.success() {
// The runner already printed the reason; propagate its code without a second error line.
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
/// Resolve how to invoke the runner CLI: the program plus any leading args (the bundled bun needs
/// the runner script path passed to it).
fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
#[cfg(target_os = "windows")]
{
// The installer lays the payload out as {app}\punktfunk-host.exe, {app}\bun\bun.exe and
// {app}\scripting\runner-cli.js (packaging/windows/punktfunk-host.iss).
let app = std::env::current_exe()
.context("resolve current exe")?
.parent()
.context("resolve install dir")?
.to_path_buf();
let bun = app.join("bun").join("bun.exe");
let runner = app.join("scripting").join("runner-cli.js");
if !bun.exists() || !runner.exists() {
bail!(
"the plugin runner isn't installed (looked for {} and {}) — reinstall punktfunk \
with the scripting component",
bun.display(),
runner.display()
);
}
// Tail expression, not `return`: after cfg-stripping this block is the whole fn body on
// Windows, and a `return` here trips clippy's needless_return under CI's -D warnings.
Ok((bun, vec![runner.to_string_lossy().into_owned()]))
}
#[cfg(not(target_os = "windows"))]
{
// The scripting package ships /usr/bin/punktfunk-scripting, a wrapper that runs the bundled
// bun on the runner bundle and forwards "$@" (packaging/debian/build-scripting-deb.sh).
let wrapper = std::path::PathBuf::from("/usr/bin/punktfunk-scripting");
if wrapper.exists() {
return Ok((wrapper, Vec::new()));
}
// Fall back to the package's private layout in case the wrapper is absent.
let bun = std::path::PathBuf::from("/usr/lib/punktfunk-scripting/bun");
let runner = std::path::PathBuf::from("/usr/share/punktfunk-scripting/runner-cli.js");
if bun.exists() && runner.exists() {
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
}
bail!(
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
`sudo apt install punktfunk-scripting`)"
)
}
}
// ---- service ops ------------------------------------------------------------------------------
#[cfg(target_os = "linux")]
fn enable() -> Result<()> {
run_systemctl(&["enable", "--now", UNIT])?;
println!("Plugin runner enabled and started ({UNIT}).");
Ok(())
}
#[cfg(target_os = "linux")]
fn disable() -> Result<()> {
run_systemctl(&["disable", "--now", UNIT])?;
println!("Plugin runner stopped and disabled ({UNIT}).");
Ok(())
}
#[cfg(target_os = "linux")]
fn status() -> Result<()> {
let active = systemctl_output(&["is-active", UNIT]).unwrap_or_else(|| "unknown".into());
let enabled = systemctl_output(&["is-enabled", UNIT]).unwrap_or_else(|| "unknown".into());
println!("runner: {UNIT}\nenabled: {enabled}\nactive: {active}");
if active != "active" {
println!("\nStart it with: punktfunk-host plugins enable");
}
Ok(())
}
#[cfg(target_os = "linux")]
fn run_systemctl(args: &[&str]) -> Result<()> {
let status = Command::new("systemctl")
.arg("--user")
.args(args)
.status()
.context("failed to run systemctl (is systemd available in this session?)")?;
if !status.success() {
bail!(
"systemctl --user {} failed — is the punktfunk-scripting package installed?",
args.join(" ")
);
}
Ok(())
}
/// Trimmed stdout of a `systemctl --user` query, or `None` if it couldn't run. These queries exit
/// non-zero for a normal "inactive"/"disabled" answer, so the status text is what matters.
#[cfg(target_os = "linux")]
fn systemctl_output(args: &[&str]) -> Option<String> {
let out = Command::new("systemctl")
.arg("--user")
.args(args)
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(target_os = "windows")]
fn enable() -> Result<()> {
powershell(&format!(
"Enable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null; \
Start-ScheduledTask -TaskName {TASK} -ErrorAction Stop"
))?;
println!("Plugin runner enabled and started ({TASK}).");
Ok(())
}
#[cfg(target_os = "windows")]
fn disable() -> Result<()> {
powershell(&format!(
"Stop-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
Disable-ScheduledTask -TaskName {TASK} -ErrorAction Stop | Out-Null"
))?;
println!("Plugin runner stopped and disabled ({TASK}).");
Ok(())
}
#[cfg(target_os = "windows")]
fn status() -> Result<()> {
let out = powershell_output(&format!(
"$t = Get-ScheduledTask -TaskName {TASK} -ErrorAction SilentlyContinue; \
if ($null -eq $t) {{ 'missing' }} else {{ \
$i = Get-ScheduledTaskInfo -TaskName {TASK} -ErrorAction SilentlyContinue; \
\"$($t.State)\" }}"
));
match out.as_deref().map(str::trim) {
Some("missing") | None => {
println!(
"runner: {TASK}\nstate: not installed\n\nReinstall punktfunk with the \
scripting component to get the plugin runner."
);
}
Some(state) => {
println!("runner: {TASK}\nstate: {state}");
if state.eq_ignore_ascii_case("Disabled") {
println!("\nEnable it with: punktfunk-host plugins enable");
}
}
}
Ok(())
}
/// Resolve powershell by full System32 path rather than PATH — CreateProcess searches the launching
/// EXE's own directory first, so a planted `powershell.exe` beside the host binary would otherwise
/// run with our privileges (security-review 2026-07-17; matches service.rs / pf_vdisplay).
#[cfg(target_os = "windows")]
fn powershell_path() -> String {
std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string())
}
#[cfg(target_os = "windows")]
fn powershell(command: &str) -> Result<()> {
let status = Command::new(powershell_path())
.args(["-NoProfile", "-NonInteractive", "-Command", command])
.status()
.context("failed to run powershell")?;
if !status.success() {
bail!(
"the {TASK} scheduled task couldn't be changed — is punktfunk installed with the \
scripting component, and is this prompt elevated?"
);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn powershell_output(command: &str) -> Option<String> {
let out = Command::new(powershell_path())
.args(["-NoProfile", "-NonInteractive", "-Command", command])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
// ---- elevation --------------------------------------------------------------------------------
/// Refuse early, with an actionable message, when an admin-only operation is run unelevated. We do
/// NOT self-elevate via UAC: that spawns a separate console window which closes on exit, hiding
/// bun's install output and any error the user needs to read.
#[cfg(target_os = "windows")]
fn require_elevation(what: &str) -> Result<()> {
if is_elevated() {
return Ok(());
}
// ASCII only: the Windows console's default codepage drops non-ASCII (an em-dash or arrow
// renders as a blank), which mangles the one message the user most needs to read.
bail!(
"{what} needs administrator rights (the plugins directory under %ProgramData%\\punktfunk \
and the runner task are admin-owned).\n\nOpen an elevated prompt: Start -> type \
\"PowerShell\" -> right-click -> Run as administrator, then run this command again."
)
}
/// Does this process have local-Administrator rights *in effect*?
///
/// Deliberately `CheckTokenMembership` against the built-in Administrators group, NOT
/// `GetTokenInformation(TokenElevation)`. `TokenElevation` answers "was this token elevated via
/// UAC", which is not the same question: a restricted/SAFER token derived from an elevated one
/// (`runas /trustlevel:0x20000`) still reports `TokenIsElevated = 1` while the Administrators SID
/// is deny-only, so the guard waved through a process that then failed on the ACL'd plugins dir.
/// Verified on-glass 2026-07-19: under such a token this returns false where `TokenElevation`
/// returned true. `CheckTokenMembership(None, …)` uses the effective token and honors deny-only
/// SIDs — the same test PowerShell's `IsInRole([…]::Administrator)` performs.
#[cfg(target_os = "windows")]
fn is_elevated() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
AllocateAndInitializeSid, CheckTokenMembership, FreeSid, PSID, SID_IDENTIFIER_AUTHORITY,
};
// The well-known BUILTIN\Administrators SID, S-1-5-32-544: NT authority (5) + the
// SECURITY_BUILTIN_DOMAIN_RID (32) and DOMAIN_ALIAS_RID_ADMINS (544) sub-authorities. Spelled
// out rather than imported so this doesn't depend on which module the crate exposes the RID
// constants from.
const NT_AUTHORITY: SID_IDENTIFIER_AUTHORITY = SID_IDENTIFIER_AUTHORITY {
Value: [0, 0, 0, 0, 0, 5],
};
const BUILTIN_DOMAIN_RID: u32 = 32;
const ALIAS_RID_ADMINS: u32 = 544;
let mut admins = PSID::default();
// SAFETY: AllocateAndInitializeSid is given a valid authority and exactly the 2 sub-authorities
// its count argument declares (the remaining 6 are the API's required zero padding). On success
// it yields a valid PSID that we pass to CheckTokenMembership and free on every path below;
// `None` for the token means "the calling thread's effective token".
unsafe {
if AllocateAndInitializeSid(
&NT_AUTHORITY,
2,
BUILTIN_DOMAIN_RID,
ALIAS_RID_ADMINS,
0,
0,
0,
0,
0,
0,
&mut admins,
)
.is_err()
{
return false;
}
let mut is_member = windows::core::BOOL::default();
let ok = CheckTokenMembership(Some(HANDLE::default()), admins, &mut is_member).is_ok();
FreeSid(admins);
ok && is_member.as_bool()
}
}
// Non-Linux, non-Windows (macOS dev builds): the runner and its service manager don't exist there.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn enable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn disable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
fn status() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
+2 -1
View File
@@ -149,7 +149,8 @@ directory of scripts and installed plugins as one service: crash-restarts with b
for the five-line quickstart and unit templates.
For ready-made plugins — sync your ROM collection or your Playnite library into the game library,
with a console page to manage them — see [Plugins](/docs/plugins).
with a console page to manage them — see [Plugins](/docs/plugins). Installing one is two commands:
`punktfunk-host plugins add <name>`, then `punktfunk-host plugins enable`.
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
`pairing.pending`, send yourself a notification, and call
-100
View File
@@ -1,100 +0,0 @@
---
title: Plugins
description: First-party plugins that sync your ROM collection or your Playnite library into the game library — and how to install them.
---
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your **game library** as a provider — so they
appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
Two first-party plugins today:
| Plugin | What it does |
|---|---|
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
## Installing a plugin
Plugins install as packages into the runner's plugins directory, and the runner
(`punktfunk-scripting`) supervises them. It's a one-time registry setup, then `bun add`:
```sh
# The runner's plugins directory:
# Linux ~/.config/punktfunk/plugins
# Windows %ProgramData%\punktfunk\plugins
cd ~/.config/punktfunk/plugins # create it if it doesn't exist yet
# Point the @punktfunk scope at the registry (once):
cat > bunfig.toml <<'EOF'
[install.scopes]
"@punktfunk" = "https://git.unom.io/api/packages/unom/npm/"
EOF
bun add @punktfunk/plugin-rom-manager # or @punktfunk/plugin-playnite
```
Then enable the runner — it's opt-in, off until you turn it on:
```sh
systemctl --user enable --now punktfunk-scripting # Linux
Enable-ScheduledTask PunktfunkScripting # Windows
```
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install. Each plugin is also fully configurable from a `config.json` for headless
hosts (see its README).
> Plugins are operator-installed code that runs as the host user — they can launch games and run
> commands. Install only plugins you trust, from a registry you control.
## ROM Manager
`@punktfunk/plugin-rom-manager` — point it at your ROM directories and it scans them, matches each
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro thumbnails),
and reconciles the result into your library as the `rom-manager` provider. ~25 built-in platforms
(NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a console page to
configure it all.
Install it as [above](#installing-a-plugin), then add a root or two — from the console's **ROM
Manager** page, or in `~/.config/punktfunk/rom-manager/config.json`:
```jsonc
{
"roots": [
{ "dir": "/mnt/roms/snes", "platform": "snes" },
{ "dir": "/mnt/roms/ps1", "platform": "ps1", "excludes": ["*.sav"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
```
Full options and the platform/emulator list are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
## Playnite
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (Steam,
GOG, Epic, Xbox, itch, emulators, manually-added games — everything Playnite manages) into your
library. Launching a title hands it back to Playnite, which performs the real launch, so there are no
per-store launch commands to maintain. Covers are served by the host, so it scales to large libraries.
Because Playnite keeps its library locked while running, this plugin has **two parts**, both on the
Windows host:
1. **The plugin** (on the host) — install `@punktfunk/plugin-playnite` exactly as
[above](#installing-a-plugin).
2. **The Punktfunk Sync extension** (in Playnite) — download `punktfunk-sync.pext` from the
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) and **double-click
it** to install it in Playnite like any add-on, then restart Playnite once.
Open the console's **Playnite** page — it shows "Exporter connected", and your games sync within
seconds of any library change. Filters (installed-only, per-store, hidden) live on that page or in
`~/.config/punktfunk/playnite/config.json`. Details are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
## Writing your own
A plugin is a small TypeScript module built on `@punktfunk/host` (`definePlugin`), supervised by the
runner. Start from the [SDK README](https://git.unom.io/unom/punktfunk/src/branch/main/sdk) — or the
two plugins above, which are worked examples of the same shape.
+185
View File
@@ -0,0 +1,185 @@
---
title: Plugins
description: First-party plugins that sync your ROM collection or your Playnite library into the game library — and how to install them.
---
Plugins extend the host through the **scripting runner** (see [Events & hooks](/docs/automation)). A
plugin runs alongside the host, reconciles titles into your **game library** as a provider — so they
appear in the grid on every client — and can add its own page to the [web console](/docs/web-console).
Two first-party plugins today:
| Plugin | What it does |
|---|---|
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
## Installing a plugin
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
the plugins directory, pointing it at the package registry, and starting the supervisor.
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
```sh
punktfunk-host plugins add playnite # or: rom-manager
punktfunk-host plugins enable # turn the runner on (once)
```
</Tab>
<Tab value="Windows">
Run these from an **elevated** PowerShell — right-click **PowerShell** → **Run as administrator**.
The plugins directory lives under `%ProgramData%\punktfunk`, which is admin-owned, and the runner
task runs as SYSTEM.
```powershell
punktfunk-host plugins add playnite # or: rom-manager
punktfunk-host plugins enable # turn the runner on (once)
```
If `punktfunk-host` isn't found, open a **new** terminal (the installer adds it to `PATH`), or use
the full path: `& "$env:ProgramFiles\punktfunk\punktfunk-host.exe" plugins add playnite`.
</Tab>
</Tabs>
Open the [web console](/docs/web-console) and the plugin's page appears in the nav automatically —
that's the whole install.
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
`enable` once — plugins you add later are picked up automatically.
### The rest of the commands
| Command | What it does |
|---|---|
| `punktfunk-host plugins add <name…>` | Install one or more plugins. |
| `punktfunk-host plugins remove <name…>` | Uninstall. |
| `punktfunk-host plugins list` | List what's installed, with versions. |
| `punktfunk-host plugins enable` | Enable + start the runner. |
| `punktfunk-host plugins disable` | Stop + disable the runner. |
| `punktfunk-host plugins status` | Is the runner enabled and running? |
A bare name resolves to the first-party package — `playnite` installs `@punktfunk/plugin-playnite`.
A scoped (`@scope/pkg`) or `punktfunk-plugin-*` name is used verbatim, so third-party plugins work
the same way.
> Plugins are operator-installed code that runs as the host user — they can launch games and run
> commands. Install only plugins you trust, from a registry you control.
## ROM Manager
`@punktfunk/plugin-rom-manager` — point it at your ROM directories and it scans them, matches each
platform to an installed emulator, fetches box art (SteamGridDB, or the keyless libretro thumbnails),
and reconciles the result into your library as the `rom-manager` provider. ~25 built-in platforms
(NES through Switch, PS1/2/PSP, Dreamcast, and more), per-game overrides, and a console page to
configure it all.
```sh
punktfunk-host plugins add rom-manager
```
Then add a root or two — from the console's **ROM Manager** page, or in the config file:
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
`~/.config/punktfunk/rom-manager/config.json`:
```jsonc
{
"roots": [
{ "dir": "/mnt/roms/snes", "platform": "snes" },
{ "dir": "/mnt/roms/ps1", "platform": "ps1", "excludes": ["*.sav"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
```
</Tab>
<Tab value="Windows">
`%ProgramData%\punktfunk\rom-manager\config.json`:
```jsonc
{
"roots": [
{ "dir": "D:\\roms\\snes", "platform": "snes" },
{ "dir": "D:\\roms\\ps1", "platform": "ps1", "excludes": ["*.sav"] }
],
"art": { "provider": "auto", "steamGridDbKey": "" }
}
```
</Tab>
</Tabs>
Full options and the platform/emulator list are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
## Playnite
`@punktfunk/plugin-playnite` — mirrors your **[Playnite](https://playnite.link)** library (Steam,
GOG, Epic, Xbox, itch, emulators, manually-added games — everything Playnite manages) into your
library. Launching a title hands it back to Playnite, which performs the real launch, so there are no
per-store launch commands to maintain. Covers are served by the host, so it scales to large libraries.
Playnite is Windows-only, so both halves of this one live on the **Windows host**. Because Playnite
keeps its library locked while running, there are **two parts**:
1. **The plugin** — from an elevated PowerShell:
```powershell
punktfunk-host plugins add playnite
punktfunk-host plugins enable
```
2. **The Punktfunk Sync extension** (in Playnite) — download `punktfunk-sync.pext` from the
[plugin's builds](https://git.unom.io/unom/punktfunk-plugin-playnite/actions) and **double-click
it** to install it in Playnite like any add-on, then restart Playnite once.
Open the console's **Playnite** page — it shows "Exporter connected", and your games sync within
seconds of any library change. Filters (installed-only, per-store, hidden) live on that page or in
`%ProgramData%\punktfunk\playnite\config.json`. Details are in
[the plugin's repo](https://git.unom.io/unom/punktfunk-plugin-playnite).
## Troubleshooting
**`punktfunk-host: command not found`** — on Windows, open a new terminal so it picks up the
installer's `PATH` change, or call the exe by full path. On Linux the host package installs it to
`/usr/bin/punktfunk-host`.
**"the plugin runner isn't installed"** — the runner ships as its own package. On Debian/Ubuntu:
`sudo apt install punktfunk-scripting`. On Windows, re-run the installer and keep the scripting
component.
**The plugin doesn't show up in the console** — check the runner is actually running with
`punktfunk-host plugins status`, then look at its log:
<Tabs items={['Linux', 'Windows']}>
<Tab value="Linux">
```sh
journalctl --user -u punktfunk-scripting -f
```
</Tab>
<Tab value="Windows">
The runner task doesn't write a log file, so run it in the foreground to watch it start your
plugins (stop it with <kbd>Ctrl</kbd>+<kbd>C</kbd>):
```powershell
& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js"
```
</Tab>
</Tabs>
## Writing your own
A plugin is a small TypeScript module built on `@punktfunk/host` (`definePlugin`), supervised by the
runner. Start from the [SDK README](https://git.unom.io/unom/punktfunk/src/branch/main/sdk) — or the
two plugins above, which are worked examples of the same shape.
+4
View File
@@ -1,4 +1,5 @@
import defaultMdxComponents from 'fumadocs-ui/mdx'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import type { MDXComponents } from 'mdx/types'
import BitrateCalculator from '@/components/BitrateCalculator'
@@ -7,6 +8,9 @@ export function getMDXComponents(components?: MDXComponents) {
...defaultMdxComponents,
// Custom components usable in any .md/.mdx without a per-file import.
BitrateCalculator,
// Per-platform instructions: <Tabs items={['Linux', 'Windows']}><Tab value="Linux">…
Tabs,
Tab,
...components,
} satisfies MDXComponents
}
+66
View File
@@ -128,6 +128,10 @@ UninstallDisplayName=punktfunk host {#MyAppVersion}
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
; copy stays as the uninstall-entry icon.
UninstallDisplayIcon={app}\punktfunk.ico
; {app} goes on the machine PATH (see [Registry] + PathNeedsAdd/RemoveAppFromPath below) so the
; documented one-liners — `punktfunk-host plugins add playnite` — work by name from an elevated
; prompt. Broadcasts WM_SETTINGCHANGE so already-open shells pick it up after a restart.
ChangesEnvironment=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
@@ -236,6 +240,14 @@ Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ig
; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here.
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
; Put {app} on the MACHINE PATH so `punktfunk-host plugins add …` / `punktfunk-host service …` are
; runnable by name. Appended to the existing value ({olddata}) and guarded by PathNeedsAdd so a
; repair/upgrade never appends a duplicate. Deliberately NOT `uninsdeletevalue` — that would delete
; the whole Path value; the uninstaller surgically removes just our entry (RemoveAppFromPath).
; expandsz preserves the %SystemRoot%-style entries other software puts here.
Root: HKLM64; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
Check: PathNeedsAdd(ExpandConstant('{app}'))
#ifdef WithVkLayer
; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads
; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD
@@ -518,6 +530,60 @@ begin
end;
#endif
const
EnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment';
{ Is the install dir missing from the machine PATH? Guards the [Registry] append so a repair or
upgrade can't add a second copy. Compared case-insensitively and semicolon-delimited so a path
that merely CONTAINS ours as a substring (...\punktfunk-old) doesn't count as a match.
NOTE: never write a braced Inno constant inside a Pascal comment - these comments do NOT nest,
so its closing brace ends the comment early and the rest of the line is parsed as code. }
function PathNeedsAdd(Param: String): Boolean;
var
OrigPath: String;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', OrigPath) then
begin
Result := True; { no Path value at all - the append creates it }
exit;
end;
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
end;
{ Remove exactly our install-dir entry from the machine PATH on uninstall, leaving every other
entry (and their order) intact. Rebuilds the value entry-by-entry rather than doing a substring
delete, so a partial match can never corrupt a neighbouring path. }
procedure RemoveAppFromPath;
var
OrigPath, NewPath, Entry: String;
Target: String;
P: Integer;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', OrigPath) then
exit;
Target := Uppercase(ExpandConstant('{app}'));
NewPath := '';
{ Walk the semicolon-delimited list, copying through everything that isn't ours. }
OrigPath := OrigPath + ';';
repeat
P := Pos(';', OrigPath);
Entry := Trim(Copy(OrigPath, 1, P - 1));
OrigPath := Copy(OrigPath, P + 1, Length(OrigPath));
if (Entry <> '') and (Uppercase(Entry) <> Target) then
begin
if NewPath <> '' then NewPath := NewPath + ';';
NewPath := NewPath + Entry;
end;
until OrigPath = '';
RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, EnvKey, 'Path', NewPath);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usPostUninstall then
RemoveAppFromPath;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
+18
View File
@@ -195,6 +195,24 @@ bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punkt
bun src/runner-cli.ts --list # show what it found
```
The same CLI manages plugin packages — it creates the plugins dir, points it at the `@punktfunk`
registry, and installs on the bun it is already running on:
```sh
bun src/runner-cli.ts add playnite # → @punktfunk/plugin-playnite (bare names resolve first-party)
bun src/runner-cli.ts remove playnite
bun src/runner-cli.ts list # installed plugin packages + versions
```
On an installed host these are reached through the host CLI, which also drives the runner service
and checks for elevation on Windows — that is the documented path for operators:
```sh
punktfunk-host plugins add playnite
punktfunk-host plugins enable # enable + start the runner (opt-in)
punktfunk-host plugins status
```
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
restarts them with capped exponential backoff; a clean return completes them.
+154
View File
@@ -0,0 +1,154 @@
// `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");
/**
* 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 `<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.
*/
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 `<config_dir>/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 `<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.
*/
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;
};
+77 -4
View File
@@ -1,10 +1,21 @@
#!/usr/bin/env bun
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
// every plugin's finalizers run before exit (the systemd-stop story).
// `punktfunk-scripting` — the plugin/script runner AND the `punktfunk-host plugins …` package ops.
//
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
// 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 (playnite, rom-manager) or punktfunk-plugin-* packages
// 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)
import { Effect, Fiber } from "effect";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import { discoverUnits, runner } from "./runner.js";
const arg = (flag: string): string | undefined => {
@@ -17,6 +28,68 @@ const options = {
pluginsDir: arg("--plugins"),
};
// 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") {
i++; // skip its value too
continue;
}
if (a.startsWith("-")) continue;
out.push(a);
}
return out;
};
const pkgOpts = { dir: options.pluginsDir };
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);
+132
View File
@@ -0,0 +1,132 @@
// The `punktfunk-host plugins …` package-op helpers: friendly-name resolution, the bunfig scope
// wiring (idempotent + merges into an existing file), and installed-plugin discovery. `add`/`remove`
// shell out to bun over the network, so the live install is verified on-glass, not here.
import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import {
ensureBunfig,
ensurePluginsDir,
listInstalled,
REGISTRY,
resolvePackage,
} from "../src/plugins.js";
const ROOT = path.join(import.meta.dir, "..", `.plugins-fixtures-${process.pid}`);
fs.mkdirSync(ROOT, { recursive: true });
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const tmp = (name: string): string => {
const dir = path.join(ROOT, name);
fs.mkdirSync(dir, { recursive: true });
return dir;
};
const writePkg = (dir: string, name: string, version: string) => {
const pkgDir = path.join(dir, "node_modules", name);
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify({ name, version }),
);
};
describe("resolvePackage", () => {
test("maps bare first-party names into the @punktfunk scope", () => {
expect(resolvePackage("playnite")).toBe("@punktfunk/plugin-playnite");
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
});
test("passes through scoped, unscoped-convention, and pathed names verbatim", () => {
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
"@punktfunk/plugin-playnite",
);
expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x");
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
});
test("trims and rejects empty", () => {
expect(resolvePackage(" playnite ")).toBe("@punktfunk/plugin-playnite");
expect(() => resolvePackage(" ")).toThrow();
});
});
describe("ensureBunfig", () => {
test("writes the @punktfunk scope map when absent", () => {
const dir = tmp("bunfig-fresh");
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain("[install.scopes]");
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
test("is idempotent — a second call doesn't duplicate the scope", () => {
const dir = tmp("bunfig-idempotent");
ensureBunfig(dir);
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml.match(/@punktfunk/g)?.length).toBe(1);
});
test("merges into an existing [install.scopes] table, keeping other scopes", () => {
const dir = tmp("bunfig-merge");
fs.writeFileSync(
path.join(dir, "bunfig.toml"),
'[install.scopes]\n"@other" = "https://example.test/npm/"\n',
);
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain('"@other" = "https://example.test/npm/"');
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
expect(toml.match(/\[install\.scopes\]/g)?.length).toBe(1);
});
test("appends a table to an unrelated existing bunfig", () => {
const dir = tmp("bunfig-append");
fs.writeFileSync(path.join(dir, "bunfig.toml"), "telemetry = false\n");
ensureBunfig(dir);
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
expect(toml).toContain("telemetry = false");
expect(toml).toContain("[install.scopes]");
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
});
});
describe("listInstalled", () => {
test("returns empty for a dir with no node_modules", () => {
expect(listInstalled(tmp("list-empty"))).toEqual([]);
});
test("finds both scoped and unscoped plugins with versions, ignoring other packages", () => {
const dir = tmp("list-mixed");
writePkg(dir, "punktfunk-plugin-custom", "1.2.3");
writePkg(dir, path.join("@punktfunk", "plugin-playnite"), "0.2.0");
writePkg(dir, "effect", "4.0.0"); // an ordinary dep — must not be listed
writePkg(dir, path.join("@punktfunk", "host"), "0.1.1"); // scoped non-plugin — ignored
const found = listInstalled(dir);
expect(found).toEqual([
{ pkg: "@punktfunk/plugin-playnite", version: "0.2.0" },
{ pkg: "punktfunk-plugin-custom", version: "1.2.3" },
]);
});
test("tolerates a plugin with an unreadable package.json", () => {
const dir = tmp("list-broken");
fs.mkdirSync(path.join(dir, "node_modules", "punktfunk-plugin-broken"), {
recursive: true,
});
expect(listInstalled(dir)).toEqual([
{ pkg: "punktfunk-plugin-broken", version: undefined },
]);
});
});
describe("ensurePluginsDir", () => {
test("creates the dir (and parents) and returns it", () => {
const dir = path.join(tmp("ensure-dir"), "nested", "plugins");
expect(ensurePluginsDir(dir)).toBe(dir);
expect(fs.statSync(dir).isDirectory()).toBe(true);
ensurePluginsDir(dir); // idempotent
});
});