Skip to content

Extending Writing Lua Plugins

Leonard Ramminger edited this page May 9, 2026 · 7 revisions

Writing Lua Plugins

Prev: Choosing an Extension Model | Up: Extending ReqPack | Next: Building Registry Entries

Lua plugins are the main extension point for ReqPack. They implement package-manager behavior while ReqPack provides config, planning, audit integration, output handling, and remote execution. This page describes normal wrapper or proxy plugins loaded through LuaBridge, not hook scripts inside native .rqp packages.

Where Plugins Live

Local plugin bundle layout:

plugins/
  <plugin-id>/
    metadata.json
    reqpack.lua
    run.lua
    scripts/
      install.lua
      remove.lua

Important:

  • plugin implementation must expose global plugin table,
  • metadata.json.name is plugin id used for discovery,
  • planner and executor dependencies belong in reqpack.lua depends,
  • scripts/install.lua and scripts/remove.lua must exist even if they only return true.

Examples in this repo:

  • plugins/dnf/run.lua
  • plugins/maven/run.lua
  • plugins/java/run.lua
  • plugins/sys/run.lua

Required Plugin Contract

ReqPack validates the Lua plugin contract at load time. These methods are required:

function plugin.getName() end
function plugin.getVersion() end
function plugin.getRequirements() end
function plugin.getCategories() end
function plugin.getMissingPackages(packages) end
function plugin.install(context, packages) end
function plugin.installLocal(context, path) end
function plugin.remove(context, packages) end
function plugin.update(context, packages) end
function plugin.list(context) end
function plugin.search(context, prompt) end
function plugin.info(context, packageName) end

Optional methods:

function plugin.init() end
function plugin.shutdown() end
function plugin.outdated(context) end
function plugin.resolvePackage(context, package) end
function plugin.resolveProxyRequest(context, request) end
function plugin.getSecurityMetadata() end

Optional static data:

plugin.fileExtensions = { ".rpm" }

plugin.fileExtensions is metadata table, not function.

Minimal Wrapper Example

plugin = {}

function plugin.getName()
  return "Example Manager"
end

function plugin.getVersion()
  return "1.0.0"
end

function plugin.getRequirements()
  return {}
end

function plugin.getCategories()
  return { "Example" }
end

function plugin.getMissingPackages(packages)
  return packages
end

function plugin.install(context, packages)
  context.tx.begin_step("install example packages")
  local result = context.exec.run("example-pm install ...")
  if not result.success then
    context.tx.failed("example install failed")
    return false
  end
  context.events.installed(packages)
  context.tx.success()
  return true
end

function plugin.installLocal(context, path)
  return false
end

function plugin.remove(context, packages)
  return true
end

function plugin.update(context, packages)
  return true
end

function plugin.list(context)
  return {}
end

function plugin.search(context, prompt)
  return {}
end

function plugin.info(context, packageName)
  return {}
end

The context API

ReqPack exposes a rich plugin context.

Basic plugin metadata

context.plugin.id
context.plugin.dir
context.plugin.script

Flags and host info

context.flags
context.host
context.proxy
context.repositories

Logging

context.log.debug("...")
context.log.info("...")
context.log.warn("...")
context.log.error("...")

Transaction and progress events

context.tx.status(42)
context.tx.progress(50)
context.tx.begin_step("resolve dependencies")
context.tx.commit()
context.tx.success()
context.tx.failed("something went wrong")

context.tx.progress(...) can also accept a richer payload with byte counters and rates.

Domain events

context.events.installed(payload)
context.events.deleted(payload)
context.events.updated(payload)
context.events.listed(payload)
context.events.searched(payload)
context.events.informed(payload)
context.events.outdated(payload)
context.events.unavailable(payload)

These events feed ReqPack's display and structured output layers.

Command execution and helper services

local result = context.exec.run("dnf repoquery curl")
local tmpDir = context.fs.get_tmp_dir()
local ok = context.net.download(url, destination)
context.artifacts.register({ type = "file", path = "/tmp/out" })

Important runtime boundaries:

  • context.fs in Lua plugins currently exposes temp-directory helper, not general copy/mkdir helpers.
  • context.net.download(...) returns success boolean.
  • context.artifacts.register(...) records artifact payload for ReqPack; shape is plugin-defined JSON-like table.
  • if you need richer filesystem helpers for install/remove hooks, that is native rqp hook runtime, not normal Lua plugin runtime.

There is also a global namespace:

local result = reqpack.exec.run("command -v dnf >/dev/null 2>&1")
local host = reqpack.host

Use context.exec.run(...) inside action methods when possible, because it carries the current item identity and display correlation.

Data Types You Receive

package in install, remove, update, resolvePackage

Relevant fields:

  • action
  • system
  • name
  • version
  • sourcePath
  • localTarget
  • flags
  • directRequest

request in resolveProxyRequest

  • action
  • system
  • packages
  • flags
  • outputFormat
  • outputPath
  • localPath
  • usesLocalTarget

PackageInfo for list, search, info, outdated

Useful fields include:

  • name, packageId, version, latestVersion
  • status, installed
  • summary, description
  • homepage, documentation, sourceUrl, repository
  • channel, section, packageType, architecture, license
  • dependencies, optionalDependencies, provides, conflicts, replaces, binaries, tags
  • extraFields for additional key/value output

You do not need to fill every field. Fill the ones your ecosystem can provide reliably.

getMissingPackages() Matters More Than It Looks

ReqPack uses getMissingPackages() during planning to avoid redundant work.

Good plugin behavior:

  • return only packages that still need action,
  • for installs: packages not already present,
  • for removes: packages that are actually present,
  • for updates: packages with available newer versions.

If this function is lazy and always returns everything, ReqPack still works, but planning quality and UX get worse.

resolvePackage() Improves Security and SBOM Quality

If your plugin can resolve a package into an exact version, implement resolvePackage().

This helps with:

  • vulnerability matching,
  • SBOM exports,
  • explicit package identity,
  • better audit decisions when users do not specify exact versions.

Without it, unresolved-version policy may block installs or reduce audit fidelity.

Testing a New Plugin Against Audit and SBOM

When you add a new ecosystem, this is the practical minimum validation path:

  1. implement resolvePackage() if exact version lookup is possible,
  2. return osvEcosystem from getSecurityMetadata() or configure security.osvEcosystemMap,
  3. run audit with a local feed path,
  4. run SBOM export for explicit packages and installed packages.

Useful commands:

rqp audit <system> <package> --osv-feed ./test-data/osv --osv-refresh always
rqp audit <system> --strict-ecosystem-mapping
rqp sbom <system> --format json

If audit fails with ecosystem-mapping or unresolved-version errors, fix those before calling the plugin production-ready.

Hermetic Plugin Conformance With rqp test-plugin

ReqPack now ships a hermetic plugin test command for wrapper/plugin authors.

Use it when you want to validate plugin behavior without talking to a real package manager:

rqp test-plugin --plugin ./plugins/demo --case ./cases/install.lua
rqp test-plugin --plugin demo --cases ./tests/plugins/demo
rqp test-plugin --plugin demo --preset core --report ./plugin-test-report.json

Supported inputs:

  • --plugin <value>: plugin id, plugin bundle directory, or direct script path
  • --case <file.lua>: add one Lua case file
  • --cases <dir>: add all *.lua files in directory
  • --preset core: add preset cases from <plugin>/.reqpack-test/core/
  • --report <file.json>: write JSON summary report

Case files are Lua tables, not JSON. That keeps runtime small because ReqPack already embeds Lua.

Minimal case:

return {
  name = "install success",
  request = {
    action = "install",
    system = "demo",
    packages = {
      { name = "curl", version = "8.0" }
    }
  },
  fakeExec = {
    {
      match = "demo-pm install curl",
      exitCode = 0,
      stdout = "ok\n",
      stderr = "",
      success = true,
    }
  },
  expect = {
    success = true,
    commands = { "demo-pm install curl" },
    stdout = { "ok\n" },
    events = { "installed", "success" },
    eventPayloads = {
      installed = "{name=curl}",
      success = "ok",
    },
  }
}

What ReqPack checks today:

  • command strings executed through context.exec.run(...) or reqpack.exec.run(...)
  • boolean success/failure
  • fake stdout and stderr returned by command layer
  • emitted event names
  • event payload text
  • registered artifacts
  • first query result count/name/version

Practical advice:

  • Use explicit match strings that uniquely identify one command.
  • Keep fake stdout deterministic and easy to parse.
  • Add at least one failure case, not only happy paths.
  • Put reusable baseline cases in .reqpack-test/core/ so --preset core works.
  • Still run a few real smoke tests later for target OS/package manager.

resolveProxyRequest() for Proxy Plugins

Proxy plugins should not implement real package-manager behavior directly. They should transform the request and hand it to a real target system.

Minimal shape:

function plugin.resolveProxyRequest(context, request)
  return {
    targetSystem = "maven",
    flags = request.flags,
    packages = request.packages,
  }
end

Rules:

  • do not return both packages and localPath
  • do not resolve to yourself
  • stay within configured context.proxy.targets when present
  • if you return flags, they replace request flags for next resolved request

Security Metadata

If you want better trust and security behavior, implement getSecurityMetadata().

Example:

function plugin.getSecurityMetadata()
  return {
    role = "package-manager",
    capabilities = { "exec", "network" },
    ecosystemScopes = { "rpm" },
    writeScopes = {
      { kind = "temp" },
      { kind = "user-home-subpath", value = ".cache/dnf" },
    },
    networkScopes = {
      { host = "api.osv.dev", scheme = "https", pathPrefix = "/v1" },
    },
    privilegeLevel = "sudo",
    osvEcosystem = "RPM",
    purlType = "rpm",
    versionComparatorProfile = "rpm-evr",
    versionTokenPattern = "[A-Za-z0-9._+-]+",
    versionCaseInsensitive = false,
  }
end

This becomes especially important when security.requireThinLayer = true.

Best Practices for Lua Plugins

  • Keep the plugin thin. Let the real package manager do real package-manager work.
  • Emit events and transaction steps. They improve UX, remote output, and logs.
  • Implement installLocal() if your ecosystem supports local artifacts.
  • Implement resolvePackage() as soon as exact version resolution is possible.
  • Return realistic PackageInfo records. info and outdated should not be placeholders in mature plugins.
  • Prefer deterministic shell commands and parse outputs conservatively.
  • Use plugin.fileExtensions if local-file inference makes sense.
  • Test plugin through actual rqp commands because runtime contract is behavioral, not only structural.

Related Pages

Prev: Choosing an Extension Model | Up: Extending ReqPack | Next: Building Registry Entries

Clone this wiki locally