Skip to content

Extending Lua Plugin Cookbook

Leonard Ramminger edited this page May 10, 2026 · 1 revision

Lua Plugin Cookbook

Prev: Writing Lua Plugins | Up: Extending ReqPack | Next: Testing Lua Plugins

This page shows practical recipes for common plugin patterns. For exact runtime contract, read Writing Lua Plugins.

Minimal Thin Wrapper

Good baseline shape:

plugin = {}

function plugin.getName()
  return REQPACK_PLUGIN_ID
end

function plugin.getVersion()
  return "1.0.0"
end

function plugin.getRequirements()
  return {}
end

function plugin.getCategories()
  return { "test", "system" }
end

function plugin.getMissingPackages(packages)
  return packages or {}
end

function plugin.install(context, packages)
  context.tx.begin_step("install packages")
  local result = context.exec.run("demo-pm install " .. packages[1].name)
  if not result.success then
    context.tx.failed("install failed")
    return false
  end
  context.events.installed({ name = packages[1].name })
  context.tx.success()
  return true
end

function plugin.installLocal(context, path)
  return context.exec.run("demo-pm install-local " .. path).success
end

function plugin.remove(context, packages)
  return context.exec.run("demo-pm remove " .. packages[1].name).success
end

function plugin.update(context, packages)
  return context.exec.run("demo-pm update " .. packages[1].name).success
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

function plugin.init()
  return true
end

function plugin.shutdown()
  return true
end

return plugin

Pattern:

  • metadata methods stay side-effect free
  • action methods do real work
  • use context.tx.* and context.events.* around mutations
  • prefer context.exec.run(...) over reqpack.exec.run(...) in action methods

Batch Install / Remove / Update

ReqPack can batch many packages into one action call. Do not assume exactly one package unless plugin intentionally only supports that.

Simple batch pattern:

function plugin.install(context, packages)
  if packages == nil or #packages == 0 then
    return true
  end

  local names = {}
  for _, pkg in ipairs(packages) do
    table.insert(names, pkg.name)
  end

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

When plugin must compute one command per package, build command list first, then execute joined command or loop explicitly.

Parse name@version Safely

ReqPack may hand versioned specs as name@version in package name space depending on source. Keep parsing local and boring.

local function split_name_version(pkg)
  local raw = tostring(pkg.name or "")
  local name, embedded = raw:match("^(.+)@([^@]+)$")
  if name == nil then
    return raw, pkg.version
  end
  if pkg.version ~= nil and pkg.version ~= "" then
    return name, pkg.version
  end
  return name, embedded
end

Use explicit pkg.version first when already populated.

Emit Better Query Results

For list, search, info, and outdated, return structured records and emit matching events.

Example:

function plugin.info(context, packageName)
  local result = context.exec.run("demo-pm info " .. packageName)
  if not result.success then
    return {}
  end
  local item = {
    name = packageName,
    version = "4.5.6",
    description = result.stdout,
  }
  context.events.informed(item)
  return item
end

Why:

  • better user output
  • stronger test-plugin assertions
  • better audit/SBOM follow-on behavior when plugin later adds exact resolution

Host-aware Behavior With context.host

Use context.host, not reqpack.host, when action should reflect current request-time snapshot.

Example:

function plugin.getRequirements()
  return {}
end

function plugin.install(context, packages)
  local osFamily = context.host.platform.osFamily
  local arch = context.host.platform.arch

  if osFamily == "linux" and arch == "x86_64" then
    return context.exec.run("demo-pm install-linux64 " .. packages[1].name).success
  end

  return context.exec.run("demo-pm install-generic " .. packages[1].name).success
end

Useful fields include:

  • context.host.os.id
  • context.host.os.distroId
  • context.host.cpu.logicalCores
  • context.host.cpu.physicalCores
  • context.host.memory.totalBytes

If host data looks stale while testing, run rqp host refresh.

Proxy Plugin Recipe

Proxy plugins should not try to act like real package managers after resolution fails. Resolve once, return target system, packages or local path, and flags.

Example pattern:

local function normalized_target(value)
  local target = tostring(value or ""):lower()
  if target == "" then
    return nil
  end
  return target
end

function plugin.resolveProxyRequest(context, request)
  local proxy = context.proxy or {}
  local target = normalized_target(proxy.default)
  if target == nil and proxy.targets ~= nil then
    target = normalized_target(proxy.targets[1])
  end
  if target == nil then
    context.log.error("proxy could not choose target")
    return nil
  end

  local resolution = {
    targetSystem = target,
    flags = request.flags,
  }

  if request.usesLocalTarget then
    resolution.localPath = request.localPath
  else
    resolution.packages = request.packages
  end

  return resolution
end

Remember source-enforced rules:

  • max proxy depth is 4
  • cycles are rejected
  • self-target is rejected
  • returning nil is failure, not pass-through
  • returned packages and localPath are mutually exclusive

resolvePackage() For Better Audit / SBOM

Add this as soon as plugin can resolve exact package identity.

Minimal pattern:

function plugin.resolvePackage(context, package)
  local result = context.exec.run("demo-pm exact-version " .. package.name)
  if not result.success then
    return nil
  end

  return {
    action = package.action,
    system = package.system,
    name = package.name,
    version = result.stdout:gsub("%s+$", ""),
    sourcePath = package.sourcePath,
    localTarget = package.localTarget,
    flags = package.flags,
  }
end

This improves:

  • audit matching accuracy
  • SBOM exactness
  • unresolved-version handling

Exec Rules Recipe

Use exec rules when plugin must react to command output while command is still running.

Example:

local rules = {
  initial = "default",
  rules = {
    {
      state = "default",
      source = "line",
      regex = "^Loaded (.+)$",
      repeat = true,
      actions = {
        { type = "log", level = "info", message = "${1}" },
      },
    },
    {
      source = "line",
      regex = "^Progress ([0-9]+)%%$",
      actions = {
        { type = "progress", percent = "${1}" },
      },
    },
  },
}

local result = context.exec.run("demo-pm install huge-package", rules)

Use line rules first. Move to PTY/screen rules only when tool renders interactive full-screen or prompt-driven output.

Important:

  • malformed rule schema fails command immediately
  • any screen rule or send action switches runner to PTY mode
  • placeholders like ${1} use regex captures

pack() Recipe

Only add plugin.pack() when ecosystem has genuine native package build flow. If you are building ReqPack-native .rqp, use builtin rqp pack instead.

Minimal pattern:

function plugin.pack(context, projectPath, outputPath, flags)
  local result = context.exec.run("demo-pack build --project " .. projectPath .. " --output " .. outputPath)
  if not result.success then
    return false
  end

  context.artifacts.register(outputPath)
  return true
end

Rules worth remembering:

  • during plugin.pack(), ReqPack temporarily allows writes under projectPath
  • register artifact path strings with context.artifacts.register(...)
  • if plugin reports success for explicit output path but artifact file is missing, ReqPack treats that as failure

test-plugin Case Patterns

Happy-path list case:

return {
  name = "system fixture list",
  request = {
    action = "list",
    system = "demo-system",
  },
  fakeExec = {
    {
      match = "demo-pm list",
      exitCode = 0,
      stdout = "alpha entry",
      stderr = "",
      success = true,
    }
  },
  expect = {
    success = true,
    commands = { "demo-pm list" },
    stdout = { "alpha entry" },
    events = { "listed" },
    resultCount = 1,
    resultName = "alpha",
    resultVersion = "1.2.3",
  }
}

Happy-path info case:

return {
  name = "system fixture info",
  request = {
    action = "info",
    system = "demo-system",
    prompt = "delta",
  },
  fakeExec = {
    {
      match = "demo-pm info delta",
      exitCode = 0,
      stdout = "delta details",
      stderr = "",
      success = true,
    }
  },
  expect = {
    success = true,
    commands = { "demo-pm info delta" },
    stdout = { "delta details" },
    events = { "informed" },
    resultCount = 1,
    resultName = "delta",
    resultVersion = "4.5.6",
  }
}

Recommended mix for each plugin:

  • one happy-path query case
  • one mutating success case
  • one mutating failure case
  • one payload-format-sensitive case if parser logic matters

Best Practices

  • keep wrapper thin; underlying package manager should still own package-manager semantics
  • keep getName(), getVersion(), getSecurityMetadata(), and plugin.fileExtensions safe before init()
  • implement getMissingPackages() carefully because planning and recovery depend on it
  • prefer exact result records over placeholder query output in mature plugins
  • use real smoke tests after hermetic test-plugin coverage

Related Pages

Prev: Writing Lua Plugins | Up: Extending ReqPack | Next: Testing Lua Plugins

Clone this wiki locally