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:
@@ -65,6 +65,7 @@ mod mgmt_token;
|
|||||||
mod native;
|
mod native;
|
||||||
mod native_pairing;
|
mod native_pairing;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
mod plugins;
|
||||||
mod send_pacing;
|
mod send_pacing;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/service.rs"]
|
#[path = "windows/service.rs"]
|
||||||
@@ -249,6 +250,10 @@ fn real_main() -> Result<()> {
|
|||||||
std::process::exit(1);
|
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).
|
// Print the management API's OpenAPI document (for client codegen).
|
||||||
Some("openapi") => {
|
Some("openapi") => {
|
||||||
print!("{}", mgmt::openapi_json());
|
print!("{}", mgmt::openapi_json());
|
||||||
@@ -623,6 +628,8 @@ fn print_usage() {
|
|||||||
USAGE:
|
USAGE:
|
||||||
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
|
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
|
||||||
(secure default; add --gamestream for Moonlight compat)
|
(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 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 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)
|
punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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 the five-line quickstart and unit templates.
|
||||||
|
|
||||||
For ready-made plugins — sync your ROM collection or your Playnite library into the game library,
|
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
|
The canonical "decide, don't just observe" pattern — approve pairing from your phone: watch
|
||||||
`pairing.pending`, send yourself a notification, and call
|
`pairing.pending`, send yourself a notification, and call
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import defaultMdxComponents from 'fumadocs-ui/mdx'
|
import defaultMdxComponents from 'fumadocs-ui/mdx'
|
||||||
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||||
import type { MDXComponents } from 'mdx/types'
|
import type { MDXComponents } from 'mdx/types'
|
||||||
import BitrateCalculator from '@/components/BitrateCalculator'
|
import BitrateCalculator from '@/components/BitrateCalculator'
|
||||||
|
|
||||||
@@ -7,6 +8,9 @@ export function getMDXComponents(components?: MDXComponents) {
|
|||||||
...defaultMdxComponents,
|
...defaultMdxComponents,
|
||||||
// Custom components usable in any .md/.mdx without a per-file import.
|
// Custom components usable in any .md/.mdx without a per-file import.
|
||||||
BitrateCalculator,
|
BitrateCalculator,
|
||||||
|
// Per-platform instructions: <Tabs items={['Linux', 'Windows']}><Tab value="Linux">…
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
...components,
|
...components,
|
||||||
} satisfies MDXComponents
|
} satisfies MDXComponents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ UninstallDisplayName=punktfunk host {#MyAppVersion}
|
|||||||
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
|
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
|
||||||
; copy stays as the uninstall-entry icon.
|
; copy stays as the uninstall-entry icon.
|
||||||
UninstallDisplayIcon={app}\punktfunk.ico
|
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]
|
[Languages]
|
||||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
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.
|
; 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; \
|
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||||
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
|
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
|
#ifdef WithVkLayer
|
||||||
; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads
|
; 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
|
; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD
|
||||||
@@ -518,6 +530,60 @@ begin
|
|||||||
end;
|
end;
|
||||||
#endif
|
#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);
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
begin
|
begin
|
||||||
if CurStep = ssInstall then
|
if CurStep = ssInstall then
|
||||||
|
|||||||
@@ -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
|
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
|
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
|
||||||
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
|
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
|
||||||
restarts them with capped exponential backoff; a clean return completes them.
|
restarts them with capped exponential backoff; a clean return completes them.
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,21 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
|
// `punktfunk-scripting` — the plugin/script runner AND the `punktfunk-host plugins …` package ops.
|
||||||
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
|
|
||||||
// every plugin's finalizers run before exit (the systemd-stop story).
|
|
||||||
//
|
//
|
||||||
// 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 { Effect, Fiber } from "effect";
|
||||||
|
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||||
import { discoverUnits, runner } from "./runner.js";
|
import { discoverUnits, runner } from "./runner.js";
|
||||||
|
|
||||||
const arg = (flag: string): string | undefined => {
|
const arg = (flag: string): string | undefined => {
|
||||||
@@ -17,6 +28,68 @@ const options = {
|
|||||||
pluginsDir: arg("--plugins"),
|
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")) {
|
if (process.argv.includes("--list")) {
|
||||||
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
|
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user