Skip to content

Extending Writing Lua Plugins

Leonard Ramminger edited this page May 10, 2026 · 5 revisions

Writing Lua Plugins

Prev: Choosing an Extension Model | Up: Extending ReqPack | Next: Lua Plugin Cookbook

Lua plugins are main ReqPack extension point. They wrap package managers and proxy systems while ReqPack handles discovery, planning, request resolution, display, transactions, history, security, audit, and SBOM flows.

This page describes normal Lua plugins loaded through LuaBridge. It does not describe native .rqp hook scripts.

Bundle Layout

ReqPack recognizes plugin bundle only if all of these files exist:

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

Bundle loader requirements:

  • metadata.json must be valid JSON with non-empty formatVersion = 1, name, version, summary, description, and license.
  • reqpack.lua must return manifest table with apiVersion = 1.
  • reqpack.lua.depends is optional array of dependency specs.
  • run.lua is actual Lua plugin implementation.
  • scripts/install.lua and scripts/remove.lua are required for bundle validity even though normal LuaBridge plugins do not execute them.

Minimal reqpack.lua:

return {
  apiVersion = 1,
  depends = {
    "curl",
    "dnf:python3@3.12",
  }
}

Dependency spec forms accepted by bundle manifest:

  • package
  • package@version
  • system:package
  • system:package@version

Important versioning detail:

  • bundle manifest version is reqpack.lua.apiVersion = 1
  • runtime plugin interface version is C++ REQPACK_API_VERSION = 5

Do not mix them.

metadata.json.name becomes plugin id used by runtime and is normalized to lowercase. That id is what you see in context.plugin.id.

Examples in this repo:

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

Lua Runtime

LuaBridge opens these standard Lua libraries:

  • base
  • table
  • string
  • math
  • io

Not opened by default:

  • os
  • package
  • debug
  • coroutine

Extra globals injected by ReqPack:

  • REQPACK_PLUGIN_ID
  • REQPACK_PLUGIN_DIR
  • REQPACK_PLUGIN_SCRIPT
  • reqpack

print(...) is overridden and routed into ReqPack output. Arguments are tab-joined.

reqpack currently exposes:

reqpack.exec.run(command)
reqpack.host

Prefer context.exec.run(...) inside plugin action methods. It keeps output correlated to current item when ReqPack has single-item context.

Required Contract

Plugin must expose global plugin table.

Required methods:

Method Return Notes
plugin.getName() string Read during bridge construction, before init()
plugin.getVersion() string Read during bridge construction, before init()
plugin.getRequirements() Package[] Required by interface; keep aligned with bundle manifest
plugin.getCategories() string[] Can be called on constructed plugin before init()
plugin.getMissingPackages(packages) Package[] Source of truth for desired-state filtering
plugin.install(context, packages) bool or no return Batch install/ensure
plugin.installLocal(context, path) bool or no return Local file/archive install
plugin.remove(context, packages) bool or no return Batch remove
plugin.update(context, packages) bool or no return Batch update
plugin.list(context) PackageInfo[] Query
plugin.search(context, prompt) PackageInfo[] Query
plugin.info(context, packageName) PackageInfo Query

Optional methods:

Method Return Notes
plugin.init() bool or no return Called once on first successful loadPlugin()
plugin.shutdown() bool or no return Called on registry shutdown when enabled
plugin.outdated(context) PackageInfo[] Used by rqp outdated
plugin.resolvePackage(context, package) Package or nil Used by SBOM/audit resolution
plugin.resolveProxyRequest(context, request) ProxyResolution or nil Used during proxy expansion
plugin.getSecurityMetadata() table or nil Read during bridge construction, before init()
plugin.pack(context, projectPath, outputPath, flags) bool or no return Optional rqp pack <system> ... support

Optional static data:

plugin.fileExtensions = { ".rpm", ".deb" }

plugin.fileExtensions is table, not function. It is read during bridge construction, before init().

Boolean-return methods treat no return as success. Query methods treat no return as empty result.

Lifecycle And Call Order

Actual call flow is:

  1. Registry scans bundle directories and validates bundle layout from metadata.json and reqpack.lua.
  2. First getPlugin() or loadPlugin() constructs LuaBridge.
  3. Bridge construction executes run.lua immediately.
  4. During construction ReqPack eagerly reads: plugin.getName(), plugin.getVersion(), plugin.getSecurityMetadata(), and plugin.fileExtensions.
  5. First successful loadPlugin() validates required method contract, checks interface version, then calls optional plugin.init().
  6. Planner, request resolution, queries, and executor then call plugin methods as needed.
  7. Optional plugin.shutdown() runs when registry shuts plugins down.
  8. Temp directories created by context.fs.get_tmp_dir() are deleted during bridge shutdown.

Important behavior:

  • run.lua side effects happen before init().
  • getCategories() can be called on constructed plugin before init() because registry category lookup does not force load.
  • plugin.fileExtensions is available before init() and is used for local-target system detection.
  • Query and action methods normally run after init() because executor/request-resolution paths call loadPlugin() first.

Command-Side Behavior

ReqPack calls plugin methods like this:

ReqPack path Plugin method
proxy expansion resolveProxyRequest(context, request)
install / ensure with package names install(context, packages)
install with local file/path installLocal(context, path)
remove remove(context, packages)
update with package list update(context, packages)
update whole system update(context, {})
list list(context)
outdated outdated(context)
search search(context, prompt)
info info(context, packageName)
sbom/audit exact resolution resolvePackage(context, package)
plugin-specific pack pack(context, projectPath, outputPath, flags)

More exact semantics:

  • search() receives one string prompt built by joining all request package arguments with spaces.
  • info() receives only first requested package name.
  • list(), search(), and outdated() results are post-filtered by ReqPack for arch=<value> and type=<value> flags.
  • Executor batches packages by (action, system), so install(), remove(), and update() may receive many packages at once.
  • installLocal() receives resolved local path. Upstream CLI/orchestrator may already have downloaded URL or extracted archive before your plugin sees it.

Item correlation behavior:

  • if batch contains exactly one package, runtime events and exec output are tied to <system>:<package>
  • if call uses local target, they are tied to <system>:local
  • if call is pack, they are tied to <system>
  • multi-package batches fall back to plugin scope, not individual package rows

Bundle Dependencies vs Runtime Dependencies

Two dependency surfaces exist.

reqpack.lua.depends:

  • bundle-level dependency manifest
  • used by planner for plugin dependency provisioning
  • should be source of truth for shipped plugin bundle requirements

plugin.getRequirements():

  • still required by runtime interface
  • used by some execution/history fallback paths when bundle manifest is not available

Best practice: keep both consistent.

Minimal 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[1].name)
  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 { name = packageName, version = "1.0.0" }
end

context API

context.flags

Raw request flags for current call. These may include normal CLI flags and internal runtime markers. Plugin should ignore unknown flags it does not understand.

context.plugin

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

These map to:

  • plugin id from bundle metadata
  • plugin bundle directory
  • absolute path to run.lua

context.repositories

Ordered list of repositories for current ecosystem only. ReqPack sorts them by ascending priority before exposing them.

Each repository entry contains:

repo.id
repo.url
repo.priority
repo.enabled
repo.type

repo.auth.type
repo.auth.username
repo.auth.password
repo.auth.token
repo.auth.sshKey
repo.auth.headerName

repo.validation.checksum
repo.validation.tlsVerify

repo.scope.include
repo.scope.exclude

Any repository extras fields from config are copied as top-level keys on repository entry. Example: repo.snapshots, repo.tags.

context.proxy

Either nil or table:

context.proxy.default
context.proxy.targets
context.proxy.options

default is default target system. targets is allowed target list from config. options is string map from proxy config.

context.host

Per-call host snapshot. Optional values may be nil.

This is ReqPack host-introspection API for plugin authors. Use it when plugin needs OS family, distro info, CPU model, logical/physical core count, memory, GPU, or mounted-storage details.

Example:

local os_name = context.host.os.name
local cpu_model = context.host.cpu.model
local logical = context.host.cpu.logicalCores
local physical = context.host.cpu.physicalCores
context.host.platform.osFamily
context.host.platform.arch
context.host.platform.target
context.host.platform.supportLevel
context.host.platform.supportReason

context.host.os.family
context.host.os.id
context.host.os.name
context.host.os.version
context.host.os.versionId
context.host.os.prettyName
context.host.os.distroId
context.host.os.distroName

context.host.kernel.name
context.host.kernel.release
context.host.kernel.version

context.host.cpu.arch
context.host.cpu.vendor
context.host.cpu.model
context.host.cpu.logicalCores
context.host.cpu.physicalCores

context.host.memory.totalBytes
context.host.memory.availableBytes

context.host.gpus[i].vendor
context.host.gpus[i].model
context.host.gpus[i].driverVersion
context.host.gpus[i].backend

context.host.storage.mounts[i].device
context.host.storage.mounts[i].mountPoint
context.host.storage.mounts[i].fsType
context.host.storage.mounts[i].totalBytes
context.host.storage.mounts[i].usedBytes
context.host.storage.mounts[i].availableBytes
context.host.storage.mounts[i].readOnly

context.host.cache.schemaVersion
context.host.cache.collectedAtEpoch
context.host.cache.expiresAtEpoch
context.host.cache.refreshReason
context.host.cache.source

reqpack.host exposes same host shape, but it is bridge-global snapshot created during plugin construction. Prefer context.host for request-time data. If snapshot looks stale, run rqp host refresh.

context.log

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

These emit plugin-scoped log messages unless silent runtime mode is active.

context.tx

context.tx.status(42)
context.tx.progress(50)
context.tx.progress({
  percent = 25,
  current = 10.0,
  currentUnit = "MiB",
  total = 40.0,
  totalUnit = "MiB",
  speed = 2.5,
  speedUnit = "MiB/s",
})
context.tx.begin_step("resolve dependencies")
context.tx.commit()
context.tx.success()
context.tx.failed("something went wrong")

Progress payload rules:

  • percent is clamped to 0..100
  • current, total, and speed must be numeric
  • units accepted by progress normalization are B, KB, KiB, MB, MiB, GB, GiB, TB, TiB, and /s variants
  • if percent is missing but current and total exist, ReqPack derives percent
  • if current is missing but total and percent exist, ReqPack derives current bytes

context.events

Available event helpers:

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)

Payload serialization rules:

  • strings, booleans, and numbers become plain text
  • tables become sorted text like {key=value, other=value}
  • unsupported non-table Lua values serialize as <lua-value>

These events feed display and structured output.

Special unavailable behavior:

  • executor uses recent unavailable events during transactional failure handling
  • payload should match current package request spec exactly
  • current executor matches name or name-version

context.artifacts.register(payload)

Registers artifact payload for ReqPack. Payload is serialized with same rules as event payloads.

For plugin.pack(), register artifact path strings. rqp pack uses plugin.takeRecentArtifacts() after call.

context.exec.run(...)

Overloads:

local result = context.exec.run("command")
local result = context.exec.run("command", rules)

Return type:

result.success
result.exitCode
result.stdout
result.stderr

Actual runner behavior:

  • shell command runs as /bin/sh -c <command>
  • stdout and stderr are merged into same transcript
  • merged transcript is always appended to result.stdout
  • on success, result.stderr is normally empty even if command wrote to stderr
  • on non-zero exit, if no runner-level read error happened, ReqPack copies merged transcript into result.stderr

If you need output parsing side effects, pass exec rules as second argument.

context.fs.get_tmp_dir()

Creates temp directory and returns its path. ReqPack deletes these temp directories during plugin shutdown.

context.net.download(url, destinationPath)

Downloads resource to destination path and returns true or false.

Behavior details:

  • Lua API gets boolean only, not DownloadResult
  • if source looks like archive, ReqPack may extract it in place
  • destination path may become directory after extraction
  • local file:// sources also work

Global reqpack namespace

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

Differences from context.exec.run(...):

  • only command-string overload exists
  • output is tied to plugin scope, not current item id
  • same exec policy still applies

Data Shapes

Package

Fields visible in Lua:

package.action
package.system
package.name
package.version
package.sourcePath
package.localTarget
package.flags

Notes:

  • incoming Package userdata uses integer action
  • when ReqPack parses plain Lua tables back into Package, it accepts integer action values or strings install, remove, update, search, list, info
  • directRequest exists in C++ but is not exposed to Lua

Request

Fields visible in Lua:

request.action
request.system
request.packages
request.flags
request.outputFormat
request.outputPath
request.localPath
request.usesLocalTarget

payloadPath exists in C++ request type but is not exposed to Lua.

PackageInfo

ReqPack accepts either PackageInfo userdata or plain Lua tables. Supported table fields are:

system
name
packageId
version
latestVersion
status
installed
summary
description
homepage
documentation
sourceUrl
repository
channel
section
packageType
type
architecture
targetSystems
license
author
maintainer
email
publishedAt
updatedAt
size
installedSize
dependencies
optionalDependencies
provides
conflicts
replaces
binaries
tags
extraFields

Notes:

  • type is alias for packageType
  • installed is string field, not boolean field
  • if summary is empty, ReqPack copies description into it
  • array fields must be string arrays
  • extraFields can be either map-like table:
extraFields = {
  homepage = "https://example.test",
  checksum = "sha256:...",
}

or array of { key = ..., value = ... } tables:

extraFields = {
  { key = "checksum", value = "sha256:..." },
}

ExecResult

{
  success = true or false,
  exitCode = 0,
  stdout = "merged command output",
  stderr = "policy error, runner error, or merged output on failure"
}

ProxyResolution

resolveProxyRequest() must return table like:

{
  targetSystem = "maven",
  packages = { "org.junit:junit:4.13" },
  flags = { "arch=noarch" },
}

Supported keys:

  • targetSystem required string
  • packages optional string array
  • localPath optional string
  • flags optional string array

packages and localPath are mutually exclusive.

If plugin defines resolveProxyRequest(), returning nil is treated as resolution failure, not pass-through.

getMissingPackages() Matters More Than It Looks

ReqPack uses getMissingPackages() in several places:

  • install/ensure request filtering
  • executor pre-dispatch filtering for install/ensure
  • transaction recovery reconciliation
  • per-package partial success/failure classification after failed install/remove/update/ensure call

Return only packages that still need desired end state.

Practical meaning:

  • install / ensure: packages not yet installed
  • remove: packages still present and still need removal
  • update: packages still not at desired version

If this function is lazy and always returns input, ReqPack still works, but planning quality, recovery, and failure classification get worse.

resolvePackage() And Version Fidelity

resolvePackage(context, package) is optional but strongly recommended if your ecosystem can resolve exact version.

ReqPack uses it for:

  • SBOM export
  • security/audit matching
  • exact package identity when request omitted version

If plugin does not implement it, executor falls back to:

  1. existing version on incoming package, or
  2. info() version lookup when possible

That fallback is weaker than real package resolution.

resolveProxyRequest() For Proxy Plugins

Proxy resolution is recursive and code-enforced.

Current rules:

  • max depth is 4
  • cycle is rejected
  • resolving to self is rejected
  • when proxy config has explicit targets, resolved target must be in that list
  • if plugin implements resolveProxyRequest(), returning nil fails request resolution
  • returned flags replace current request flags
  • returned packages replace package list and clear local target
  • returned localPath sets usesLocalTarget = true and clears package list

Minimal example:

function plugin.resolveProxyRequest(context, request)
  local target = context.proxy.default or context.proxy.targets[1]
  return {
    targetSystem = target,
    packages = request.packages,
    flags = request.flags,
  }
end

Exec Rules For context.exec.run(command, rules)

Exec rules let plugin parse command output and emit ReqPack events while command is running.

Top-level schema:

{
  initial = "default",
  rules = {
    {
      state = "default",
      source = "line",
      regex = "^Loaded (.+)$",
      repeat = true,
      stop = false,
      actions = {
        { type = "log", level = "info", message = "${1}" },
      },
    },
  },
}

Parser rules:

  • rules must be contiguous 1-based array-style table
  • top-level keys allowed only: initial, rules
  • each rule must contain source, regex, actions
  • source must be line or screen
  • regex uses C++ std::regex ECMAScript syntax
  • actions must be non-empty contiguous 1-based array-style table
  • unknown keys are errors
  • nested tables are not allowed inside action field values

Rule fields:

Field Type Default Notes
state string any state Rule runs only when current state matches
source "line" or "screen" required screen implies PTY runner
regex string required Compiled as ECMAScript regex
actions action array required Executed in order on match
repeat boolean true false disables rule after first match
stop boolean false Stops further rule evaluation for current pass only

Supported action types:

type Required fields Notes
send value Writes exact bytes to PTY child stdin
state value Updates runtime state, emits nothing
log message Optional `level = debug
status code Integer status code
progress one of percent, current, total, speed Use currentUnit, totalUnit, speedUnit for byte normalization
begin_step label Emits begin-step event
success none Emits success event with payload ok
failed message Emits failed event
event name Optional payload
artifact payload Emits artifact payload

Placeholder substitution:

  • ${0} is full regex match
  • ${1}, ${2}, ... are capture groups
  • substitution happens in all action fields
  • invalid placeholder token is left unchanged
  • numeric placeholder with no matching capture resolves to empty string

Runner selection:

  • empty ruleset: plain shell runner
  • rules but no screen rule and no send action: line runner
  • any screen rule or any send action: PTY runner

Line vs screen matching:

  • line rules run against each complete line, plus final unterminated line at EOF
  • line runner sees raw merged stdout/stderr lines
  • PTY runner normalizes transcript first, then applies both line and screen rules
  • screen rules search normalized cumulative PTY transcript
  • each screen rule tracks its own cursor, so next match starts after previous match for that rule

PTY transcript normalization currently does all of this:

  • strips ANSI CSI and OSC escape sequences
  • turns \r into newline
  • applies backspace removal
  • removes other control characters except newline and tab

Warnings and failures:

  • malformed rules fail command immediately with success = false, exitCode = 1
  • invalid resolved action fields produce plugin warning logs and action is skipped
  • stop = true does not stop child process; it only stops checking later rules for that current evaluation pass

Security Metadata

plugin.getSecurityMetadata() may return:

function plugin.getSecurityMetadata()
  return {
    role = "package-manager",
    capabilities = { "exec", "network" },
    ecosystemScopes = { "rpm" },
    writeScopes = {
      { kind = "temp" },
      { kind = "plugin-data", value = "state" },
      { 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

Loader normalization:

  • role is lowercased
  • capabilities are lowercased
  • privilegeLevel is lowercased
  • writeScopes[].kind is lowercased
  • networkScopes[].host and networkScopes[].scheme are lowercased

Shape rules:

  • each writeScopes entry must include kind
  • each networkScopes entry must include at least one of host, scheme, or pathPrefix

Current write-scope kinds recognized by exec policy:

kind Meaning
read-only never allows writes
temp path under system temp directory
plugin-data path under context.plugin.dir or its subpath
reqpack-cache path under ReqPack cache directory
user-home-subpath path under $HOME/<value>
system-package-paths path under /usr, /usr/local, /opt, /etc, /var/lib, or /var/cache

Thin-layer behavior today:

  • when security.requireThinLayer = true, registry can reject plugin if runtime metadata is weaker than registry record
  • context.exec.run(...) and reqpack.exec.run(...) are denied with exitCode = 126 if plugin lacks exec capability
  • commands using sudo or doas are denied unless privilegeLevel == "sudo"
  • command write targets are denied if they fall outside declared writeScopes

Pack-specific allowance:

  • during plugin.pack(), ReqPack temporarily allows writes under projectPath
  • it also allows writes under explicit output directory
  • temp dirs created with context.fs.get_tmp_dir() during pack are also allowed

Important limitation:

  • networkScopes are parsed and matched against trust records
  • current runtime exec policy does not yet use networkScopes to block context.net.download()

Testing A New Plugin Against Audit And SBOM

Minimum practical path:

  1. implement resolvePackage() if exact version lookup is possible
  2. return osvEcosystem from getSecurityMetadata() or configure security.osvEcosystemMap
  3. run audit with local feed path
  4. run SBOM export for explicit 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

Hermetic Plugin Conformance With rqp test-plugin

ReqPack ships hermetic plugin test command for wrapper authors.

Examples:

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.

ReqPack currently checks:

  • commands executed through context.exec.run(...) or reqpack.exec.run(...)
  • boolean success/failure
  • captured stdout and stderr
  • emitted event names and payloads
  • registered artifacts
  • first query result count, name, and version

Best Practices

  • Keep wrapper thin. Let underlying package manager do package-manager work.
  • Keep metadata methods side-effect free and safe before init().
  • Implement getMissingPackages() carefully. It affects planning, recovery, and failure classification.
  • Prefer context.exec.run(...) over reqpack.exec.run(...) inside action methods.
  • Emit context.tx.* and context.events.* for better UX and structured output.
  • Implement installLocal() if ecosystem supports local artifacts.
  • Implement resolvePackage() as soon as exact version lookup is possible.
  • Return realistic PackageInfo records. Avoid placeholder info() and outdated() in mature plugins.
  • For plugin.pack(), register produced artifact paths with context.artifacts.register(...).
  • Test through real rqp commands after hermetic tests.

Related Pages

Prev: Choosing an Extension Model | Up: Extending ReqPack | Next: Lua Plugin Cookbook

Clone this wiki locally