From f0c511c8fa37e575e0063592c9ec19a65082df99 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 16:34:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20punktfunk-host=20plugins=20CLI?= =?UTF-8?q?=20=E2=80=94=20add/remove/list/enable/disable/status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/punktfunk-host/src/main.rs | 7 + crates/punktfunk-host/src/plugins.rs | 389 +++++++++++++++++++++++++++ docs-site/content/docs/automation.md | 3 +- docs-site/content/docs/plugins.md | 100 ------- docs-site/content/docs/plugins.mdx | 185 +++++++++++++ docs-site/src/components/mdx.tsx | 4 + packaging/windows/punktfunk-host.iss | 66 +++++ sdk/README.md | 18 ++ sdk/src/plugins.ts | 154 +++++++++++ sdk/src/runner-cli.ts | 81 +++++- sdk/test/plugins.test.ts | 132 +++++++++ 11 files changed, 1034 insertions(+), 105 deletions(-) create mode 100644 crates/punktfunk-host/src/plugins.rs delete mode 100644 docs-site/content/docs/plugins.md create mode 100644 docs-site/content/docs/plugins.mdx create mode 100644 sdk/src/plugins.ts create mode 100644 sdk/test/plugins.test.ts diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 3956f5ea..78eb80ab 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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 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) diff --git a/crates/punktfunk-host/src/plugins.rs b/crates/punktfunk-host/src/plugins.rs new file mode 100644 index 00000000..b35ff609 --- /dev/null +++ b/crates/punktfunk-host/src/plugins.rs @@ -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 install a plugin (playnite, rom-manager, …) + punktfunk-host plugins remove 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)> { + #[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 { + 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 { + 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") +} diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index 3c898082..2fe7cc49 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -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 `, 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 diff --git a/docs-site/content/docs/plugins.md b/docs-site/content/docs/plugins.md deleted file mode 100644 index 1d6dd6c3..00000000 --- a/docs-site/content/docs/plugins.md +++ /dev/null @@ -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. diff --git a/docs-site/content/docs/plugins.mdx b/docs-site/content/docs/plugins.mdx new file mode 100644 index 00000000..af8fd616 --- /dev/null +++ b/docs-site/content/docs/plugins.mdx @@ -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. + + + + +```sh +punktfunk-host plugins add playnite # or: rom-manager +punktfunk-host plugins enable # turn the runner on (once) +``` + + + + +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`. + + + + +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 ` | Install one or more plugins. | +| `punktfunk-host plugins remove ` | 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: + + + + +`~/.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": "" } +} +``` + + + + +`%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": "" } +} +``` + + + + +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: + + + + +```sh +journalctl --user -u punktfunk-scripting -f +``` + + + + +The runner task doesn't write a log file, so run it in the foreground to watch it start your +plugins (stop it with Ctrl+C): + +```powershell +& "$env:ProgramFiles\punktfunk\bun\bun.exe" "$env:ProgramFiles\punktfunk\scripting\runner-cli.js" +``` + + + + +## 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. diff --git a/docs-site/src/components/mdx.tsx b/docs-site/src/components/mdx.tsx index 002349c4..57391fc0 100644 --- a/docs-site/src/components/mdx.tsx +++ b/docs-site/src/components/mdx.tsx @@ -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, + Tab, ...components, } satisfies MDXComponents } diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index c1060dc9..fde85ae8 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -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 diff --git a/sdk/README.md b/sdk/README.md index b89e9b08..c6d1727e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -195,6 +195,24 @@ bun src/runner-cli.ts # runs /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 `/plugins/`): supervised — a crash restarts them with capped exponential backoff; a clean return completes them. diff --git a/sdk/src/plugins.ts b/sdk/src/plugins.ts new file mode 100644 index 00000000..c41b7793 --- /dev/null +++ b/sdk/src/plugins.ts @@ -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: `/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; +}; diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index 5333bf86..3a091a7e 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -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 install first-party (playnite, rom-manager) or punktfunk-plugin-* packages +// remove 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, , …]). 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} (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); diff --git a/sdk/test/plugins.test.ts b/sdk/test/plugins.test.ts new file mode 100644 index 00000000..0d8f8041 --- /dev/null +++ b/sdk/test/plugins.test.ts @@ -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 + }); +});