Skip to main content

Lua API

The envy table, available in every manifest and spec. Lua 5.4.

Your editor has all of this as hover documentation. See Editor Setup.

Where each function works​

FunctionManifest and spec top levelPhase functionsNotes
constants, path.*, template, extend, loggingyesyespure
abspath, loadenvyesyesresolves against the calling file
importmanifest top level onlynonot installed in specs or envy lua
loadenv_bundlemanifest top level onlynomaterializes the bundle first. Requires envy 0.4.6.
copy, move, remove, exists, is_file, is_diryesyesrelative paths anchor to stage_dir in phases
run, extract, extract_allyesyes
fetchnoFETCH only
commit_fetchnoFETCH onlyneeds the cache lock
verify_hashyesyes
package, product, loadenv_specnoyessubject to needed_by
optionsin an OPTIONS functionno

Calling a phase-only function too early is an error, not undefined behavior:

envy.product: not in phase context (missing pkg)
envy.commit_fetch: can only be called from FETCH phase with cache lock active
envy.loadenv_spec: can only be called within phase functions, not at global scope
envy.loadenv_bundle: manifest scope only; a spec reaches a bundle it declared with envy.loadenv_spec(identity, module)

Constants​

ConstantValue
envy.PLATFORM"darwin", "linux", or "windows"
envy.ARCH"arm64" or "x86_64"
envy.PLATFORM_ARCHthe two joined, for example "darwin-arm64"
envy.EXE_EXT".exe" on Windows, "" elsewhere
ENVY_SHELL.BASH, .SH, .CMD, .POWERSHELLshell selectors for envy.run and DEFAULT_SHELL

ENVY_SHELL is a top-level global, not a field of envy. All four constants exist on every platform, and an incompatible choice is rejected when used.

FETCH = function(tmp_dir, opts)
local asset = "tool-" .. opts.version .. "-" .. envy.PLATFORM_ARCH .. ".tar.gz"
return "https://example.com/releases/" .. asset
end

PRODUCTS = { mytool = "bin/mytool" .. envy.EXE_EXT }

Logging​

envy.debug(msg) -- only with --verbose
envy.info(msg) -- default level
envy.warn(msg)
envy.error(msg)
envy.stdout(msg) -- bypasses the log, writes to stdout
print(...) -- routed to the log at info level

All of it goes to stderr except envy.stdout. Do not write progress chatter to stdout, because callers parse it.

To label the package's own progress rows rather than log a line, set DISPLAY instead.

Strings and tables​

envy.template(str, values)​

Replaces {{key}} placeholders. Surrounding whitespace in a placeholder is ignored, so {{ jf }} and {{jf}} are the same key.

BUILD = function(install_dir, stage_dir, fetch_dir, tmp_dir, opts)
envy.run(envy.template("{{ cmake }} --build . --parallel -j {{ jobs }}", {
cmake = envy.product("cmake"),
jobs = opts.jobs or 4,
}))
end

envy.product only works inside a phase function, so build the string there rather than at the spec's top level.

Three things are errors rather than silent surprises: a placeholder with no value, an unmatched {{ or }}, and a placeholder name that is not an identifier.

envy.template: missing value for placeholder 'a'
envy.template: unmatched '{{' (missing closing '}}')

envy.extend(target, ...)​

Appends every source array to target in place and returns it.

local args = { "--prefix", install_dir }
envy.extend(args, extra_flags, { "--verbose" })

Paths​

envy.path.join(...) -- platform separator
envy.path.basename(path) -- "z.tar.gz"
envy.path.dirname(path) -- "/x/y"
envy.path.stem(path) -- "z.tar", one extension is removed
envy.path.extension(path) -- ".gz"
envy.abspath(relative_path) -- absolute, anchored at the calling file

join uses the platform separator and abspath returns a native path, so both produce backslashes on Windows. Build every path with these rather than concatenating "/", and a spec works on all three platforms unchanged.

Every path envy hands a spec is native throughout: phase arguments, envy.package, envy.product, envy.path.*, envy.abspath, and a depot fetch function's ctx.tmp_dir and ctx.deps. envy assembles those from a cache root, manifest text, and Lua fragments, any of which may be spelled either way, so joining alone would yield C:/cache/pkg\file. Two APIs naming the same location always agree on spelling, so a product path and the same file reached through envy.package compare equal rather than differing by a separator.

The exception is archive entry paths, which stay forward-slash on every platform. That is envy.extract's and envy.extract_all's only patterns, and envy extract --only, matching the archive formats themselves.

envy.abspath is what lets a manifest name a sibling file without caring about your working directory:

PACKAGES = {
{ spec = "local.mytool@r1", source = envy.abspath("envy/mytool.lua") },
}

It requires a relative path. Handing it an absolute one is an error, because the result would not depend on the anchor.

Files​

envy.copy(src, dst) -- files or directories, recursive
envy.move(src, dst)
envy.remove(path) -- recursive
envy.exists(path) --> boolean
envy.is_file(path) --> boolean
envy.is_dir(path) --> boolean

In a phase function, relative paths resolve against stage_dir. Prefer the absolute directories the phase hands you.

INSTALL = function(install_dir, stage_dir, fetch_dir, tmp_dir, opts)
envy.copy(envy.path.join(stage_dir, "bin"), envy.path.join(install_dir, "bin"))
if envy.exists(envy.path.join(stage_dir, "share")) then
envy.copy(envy.path.join(stage_dir, "share"), envy.path.join(install_dir, "share"))
end
end

Processes​

envy.run(script, opts?)​

script is a string or an array of lines. Returns { exit_code, stdout, stderr }, where the two streams are present only with capture = true.

OptionDefaultMeaning
cwdthe phase's directoryWorking directory.
envinheritedTable merged over the inherited environment.
shellthe platform default or DEFAULT_SHELLENVY_SHELL.* or a shell config table.
quietfalseSuppress the child's output.
capturefalsePut stdout and stderr in the result.
checktrueThrow on a non-zero exit.
interactivefalseConnect the terminal, for sudo and license prompts.
local r = envy.run("git describe --tags", { capture = true, check = false })
if r.exit_code ~= 0 then
envy.warn("no tags: " .. r.stderr)
end

check = false is how you inspect an exit code. With the default, a non-zero exit aborts the phase.

Scripts are not portable by themselves. The interpreter is bash on macOS and Linux and PowerShell on Windows, so branch on envy.PLATFORM, pass shell = ENVY_SHELL.CMD or .POWERSHELL per call, or set DEFAULT_SHELL so one dialect covers everything.

check also behaves differently underneath. bash gets -e, so a failing line stops the script whatever check says. On Windows there is no -e, so check = true makes envy inject fail-fast into the generated PowerShell or cmd script, and check = false injects nothing. See Shells & Scripts.

Archives​

envy.extract(archive_path, dest_dir, opts?) --> file count
envy.extract_all(src_dir, dest_dir, opts?)
OptionMeaning
stripDrop this many leading path components.
onlyExtract just these archive-relative paths or globs, matched after strip. A directory brings its subtree. A leading ! excludes.
archivesenvy.extract_all only. Filenames or globs in src_dir to unpack whatever their extension, such as "*.whl". A leading ! keeps a file whole. Since envy 0.4.9.

only globs support * and ? within one component, ** across components, and [a-z] or [!a-z] classes.

STAGE = function(fetch_dir, stage_dir, tmp_dir, opts)
envy.extract(envy.path.join(fetch_dir, "tool.tar.gz"), stage_dir,
{ strip = 1, only = { "bin/**", "LICENSE" } })
end

Since envy 0.4.0 an entry beginning with ! excludes instead of including, and an exclusion beats an inclusion. That makes "everything but" expressible:

envy.extract(envy.path.join(fetch_dir, "sdk.tar.gz"), stage_dir,
{ only = { "**", "!docs/**", "!**/*.pdb" } })

This is envy's one selector language. only here, a spec's VENDOR list, and envy hash --tree --only are parsed and matched by the same code.

An inclusion that matches nothing is an error, so a renamed upstream directory fails loudly:

extract tool.tar.gz: 'only' entries matched no archive contents: "bin/**"

An exclusion that matches nothing is not an error. A bare "!" is.

envy handles tar with gzip, bzip2, xz, zstd, or lzma, plus zip, 7z, rar, iso, and bare compressed streams such as a lone .gz. You rarely need to care which, which matters most on Windows, where upstream ships a .zip and the same two lines of Lua unpack it.

Fetching​

These belong to the FETCH phase. See The FETCH Verb.

envy.fetch(source, { dest = dir })​

Downloads one or many sources into dest and returns the basename it wrote, or an array of them for an array argument. source is a URL string, a { source, sha256, ref, post_data, dest } table, or an array of either.

FETCH = function(tmp_dir, opts)
local name = envy.fetch({
source = "https://example.com/tool-" .. opts.version .. ".tar.gz",
sha256 = hashes[opts.version],
}, { dest = tmp_dir })
envy.commit_fetch(name)
end

dest inside a source table renames the downloaded file, and it has to be a plain filename with no separators.

envy.commit_fetch(files)​

Moves files from tmp_dir into the durable fetch directory, verifying hashes on the way. Argument forms: a filename, a { filename, sha256 } table, or an array of either.

Anything left in tmp_dir is discarded. Committing is what carries a download into the next phase and the next run.

envy.verify_hash(path, sha256)​

Returns a boolean instead of throwing. Use it when a mismatch is something you want to handle.

Dependencies​

envy.product(name)​

Resolves a product declared as a product dependency, and returns its value, usually an absolute path.

DEPENDENCIES = {
{ spec = "envy.cmake@r0", product = "cmake", needed_by = "build" },
}

BUILD = function(install_dir, stage_dir, fetch_dir, tmp_dir, opts)
envy.run({ envy.product("cmake") .. " --build . --parallel" })
end

The name resolves either from an explicit product = on a dependency entry or, failing that, from the project-wide product registry. Either way one of your own dependency entries has to name the provider, and its needed_by must already have been reached. The edge is what drove the provider through install, and the registry only answers who provides the name. A provider you reach only through someone else's edge is refused.

Undeclared access is an error naming both sides:

envy.product: pkg 'local.user@r1' does not declare product dependency on 'nope_txt'

It also works inside a source.fetch function. source.dependencies entries are wired with needed_by = spec_fetch before the fetch runs, so their products are readable there:

source = {
dependencies = { { spec = "tools.jfrog-cli@r1", source = "jfrog.lua" } },
fetch = function(tmp_dir)
envy.run(envy.product("jf") .. " rt dl specs/ " .. tmp_dir)
end,
}

See Fetch Dependencies for the strong-reference rule that comes with it.

envy.package(identity)​

Returns a declared dependency's installed package directory. Use it when you need a directory rather than a product, for example an include path. The identity matches loosely, so "helpers" finds "acme.helpers@v1", and among several matching entries the earliest needed_by wins.

envy.loadenv_spec(identity, module)​

Loads a Lua module out of a declared dependency. The identity matches the same loose way. Inside a bundle, the module path resolves against the bundle root.

module is Lua dot syntax naming a file inside the dependency, so "lib.common" loads lib/common.lua. Path separators, a leading or trailing ., and .. are all rejected, and the resolved path is checked against the load root as well.

DEPENDENCIES = {
{ spec = "acme.helpers@v1", needed_by = "fetch" },
}

FETCH = function(tmp_dir, opts)
local gh = envy.loadenv_spec("acme.helpers", "lib.github")
return gh.release_url("acme/tool", "v" .. opts.version)
end

What comes back follows the module rule. A module loaded out of a bundle also sees ENVY_BUNDLE, whose alias is nil here, since loadenv_spec names the dependency by identity. See Shipping an API with your specs.

All three respect needed_by. A dependency declared needed_by = "build" is not resolvable from FETCH, and the error says so rather than handing you a path to a half-built package.

All three also answer from your own DEPENDENCIES only. If top depends on mid and mid depends on base, then top cannot reach base:

error: envy.package: pkg 'local.top@v1' has no strong dependency on 'local.base@v1'

Declare base in top as well. The two entries name one package, so nothing is built twice. Before envy 0.3.1 the lookup walked the whole graph, and a transitive hit could hand back the wrong package's directory.

What a module hands back​

loadenv_spec, loadenv_bundle, and loadenv share one rule, the one require already teaches. A module that returns a table hands back that table. A module that returns nothing hands back the globals it assigned. Anything else is an error naming the module:

envy.loadenv_spec: module 'lib.common' returned a number; a module returns a table or nothing

Before envy 0.4.6 all three handed back the sandbox globals whatever the module returned, so the shape every Lua author writes came back empty:

local M = {}
function M.release_url(repo, tag, file) ... end
return M -- before 0.4.6, the caller got {}

Nothing failed at the load. The call site got an empty table and reported attempt to call a nil value somewhere later. A module that served both require and loadenv_spec had to assign a global and return a table. Returning the table is now enough.

What a module can read​

A module reads the globals of the file that loaded it. What the module assigns stays in its own sandbox, so loading it never changes the caller's globals. A helper can therefore follow its caller's settings without taking them as arguments:

lib/paths.lua
local M = {}

function M.vendor_dir(name)
return (VENDOR_ROOT or "vendor") .. "/" .. name
end

return M

The loading file can be a manifest, an imported manifest, a spec, or another module. Each one's own globals show through, so a helper that a component manifest loads sees the component's VENDOR_ROOT rather than the root manifest's.

This needs envy 0.4.8. Before it, a module read the root manifest's globals (in a spec, the spec's) no matter which file loaded it. A helper loaded by an imported manifest or by another module saw the root's values instead of its caller's, and nothing failed at the load.

ENVY_BUNDLE does not show through. It always names the bundle that the current load resolved, so a file that a bundled module loads with envy.loadenv sees nil rather than the outer module's bundle.

Composition​

envy.import(path)​

Runs another manifest in a sandbox and returns its globals. Requires envy 0.3.0 or newer. This is how a superproject composes a component's package list:

envy.lua
local common = envy.import("libs/common")

PACKAGES = envy.extend(common.PACKAGES, {
{ spec = "local.mytool@r1", source = "envy/mytool.lua" },
})

path is relative to the calling manifest. A directory appends envy.lua, so envy.import("libs/common") and envy.import("libs/common/envy.lua") are the same call.

What comes back is a table of every global the imported manifest assigned, which is PACKAGES and BUNDLES and anything else it set. Only PACKAGES and BUNDLES carry import bookkeeping. Assign the rest yourself:

VENDOR_ROOT = common.VENDOR_ROOT

envy reads DEFAULT_SHELL and VENDOR_ROOT from the root manifest only. An imported manifest that sets one is an error unless the root assigns the same value:

error: envy.import: /src/libs/common/envy.lua sets VENDOR_ROOT, which is read
only from the root manifest; assign it there (e.g. VENDOR_ROOT =
envy.import(...).VENDOR_ROOT)

Assigning it as above fixes the error. A different value in the root does not.

PACKAGE_DEPOTS is also root-only, but from envy 0.4.12 an imported one is not an error. envy ignores it. It does not fetch the index or build the depot's DEPENDS. To use it, assign it in the root, or merge it with the root's own:

PACKAGE_DEPOTS = envy.extend({ "s3://acme-envy-packages/packages.txt" },
common.PACKAGE_DEPOTS)

envy.extend rejects nil, so only merge components that declare a depot. An imported depot's FETCH function reads its own manifest's globals.

An imported entry stays tied to the file that wrote it:

  • Relative source paths resolve against the imported manifest's directory. A component's source = "envy/protoc.lua" means libs/common/envy/protoc.lua no matter who imports it. envy.abspath is no longer needed for this.

  • bundle = "alias" resolves against the imported BUNDLES first, then the root's. Re-exporting BUNDLES is unnecessary, and two components can use the same alias for different bundles.

  • Provenance is the imported file too. A conflict between two components names both component manifests, not the root that spliced them together. Custom-fetch cache keys are keyed the same way, so from envy 0.3.1 on a component's source.fetch gets one spec entry across every superproject that imports it, instead of one per superproject.

The project root and the SETUP working directory still name the superproject. The imported manifest supplies declarations, not a second project. A vendor path follows the project rather than the file, so it resolves against the root manifest's directory wherever it was written.

The imported file sees ENVY_IMPORTER, the absolute path of the manifest that imported it. It is nil when the file runs as a manifest on its own, which is how a component gates an entry only it needs:

libs/common/envy.lua
if not ENVY_IMPORTER then
envy.extend(PACKAGES, {
{ spec = "acme.armgcc@r1", source = "envy/acme.armgcc.lua" },
})
end

Nesting works, and a cycle is an error naming the chain:

error: Failed to execute manifest script: envy.import: 'nope' not found (resolved to /src/app/nope)
error: Failed to execute manifest script: envy.import: import cycle: /src/a/envy.lua -> /src/b/envy.lua -> /src/a/envy.lua

The imported header is otherwise inert. The one thing envy reads from it is @envy version, and only when the root manifest pins one too. Newer than the root pin is an error, because bootstrap already chose the binary:

error: Failed to execute manifest script: envy.import: /src/sub/envy.lua requires envy 0.4.0, but the root manifest pins 0.3.0
warning: envy.import: /src/sub/envy.lua pins envy 0.2.5; the root manifest pins 0.3.0

See The bootstrap boundary.

envy.loadenv_bundle(alias, module)​

Loads a Lua module out of a bundle from the manifest's own top level, fetching the bundle first. Requires envy 0.4.6 or newer. This is how a bundle ships a helper its consumers call while they are still building PACKAGES:

envy.lua
BUNDLES = {
tools = {
identity = "acme.specs@r1",
source = "https://github.com/acme/envy-specs.git",
ref = "ded36a39bbf13744f5a0e539f2f4741fecb61dd0",
},
}

local gh = envy.loadenv_bundle("tools", "lib.github")

PACKAGES = {
gh.repo("libb64", "libb64/libb64", "ce864b1d3f4b9e0e2b0a4e5f0c9d8a7b6c5d4e3f"),
gh.repo("hidapi", "libusb/hidapi", "4ebce6b0dcdd9eb9b8d8d0a0d0b9f8e7d6c5b4a3"),
}

The builder is the bundle's code. See Reaching a bundle's API from a manifest for what lib/github.lua holds.

alias is a key of the calling file's BUNDLES. Assign that table above the call, because a manifest is read top to bottom. An imported fragment resolves its own aliases against its own file, the same way one of its literal entries would, and falls back to the root manifest's BUNDLES.

module is Lua dot syntax, with the same rules as envy.loadenv_spec. What comes back follows the module rule. The module sees ENVY_BUNDLE and the calling file's globals.

An entry the helper returns is parsed exactly like one written out in the manifest, so its bundle names an alias of the consuming manifest, including one pointing at a different bundle, and it can carry vendor.

The bundle is materialized during the manifest's own global scope, which is earlier than envy fetches bundles for anything else. A bundle whose identity starts with local. and whose source is a directory is read where it stands, so edits to it land without a copy. Every other shape goes into the cache, where the bundle's own package finds it complete a moment later, so nothing is fetched twice. The fetch shows a progress bar, then an outcome row like any package:

[acme.specs@r1] installed (1.2s)

A cache hit prints nothing. Before envy 0.4.11 the fetch printed bundle acme.specs@r1: fetching, the manifest reads it and no progress bar.

Calling it anywhere but a manifest's top level, or naming an alias the calling file does not have, is an error that says which:

envy.loadenv_bundle: manifest scope only; a spec reaches a bundle it declared with envy.loadenv_spec(identity, module)
envy.loadenv_bundle: no bundle alias 'tolos' in the BUNDLES table of /src/app/envy.lua; declare it above the call, since a manifest is read top to bottom

A bundle with a custom fetch is refused by name, because its fetch function needs a phase to run in and its source.dependencies cannot be ordered this early:

envy.loadenv_bundle: bundle 'acme.cf@r1' uses a custom fetch, which needs a phase to run in; only a spec reaches one of those, with envy.loadenv_spec

A bad module path, a missing file, and a parse or execution error report as they do for envy.loadenv_spec.

ENVY_BUNDLE​

A module that envy.loadenv_bundle or envy.loadenv_spec loaded out of a bundle sees ENVY_BUNDLE, naming the bundle it came from. Requires envy 0.4.7 or newer. It is nil everywhere else: in a manifest, in a spec, in anything envy.loadenv reached (including a file a bundled module loads from beside itself), and in a module a bundle's own spec reached with require, which is plain Lua module loading.

FieldValue
identityThe bundle's identity.
aliasWhat the calling file called it. nil under envy.loadenv_spec, which resolves by identity.
rootAbsolute path of the bundle's materialized root.

It saves a helper from being handed a name its caller already typed:

lib/github.lua, inside bundle acme.specs@r1
local M = {}

function M.repo(name, repo, ref)
return {
spec = "acme.github@r0",
bundle = ENVY_BUNDLE.alias, -- whatever the consuming manifest called us
vendor = "vendor/" .. name,
options = { repo = repo, ref = ref },
}
end

return M

ENVY_BUNDLE is readable but is not one of the module's own globals, so a module that returns nothing does not hand it back to its caller.

envy.loadenv(module)​

Loads a Lua file next to the calling file. Dots are path separators, so "libs.common.helpers" means libs/common/helpers.lua. What comes back follows the module rule, and the file reads the caller's globals. Use it for shared helper files:

envy.lua
local versions = envy.loadenv("envy.versions")

PACKAGES = {
{ spec = "envy.cmake@r0", bundle = "envy", options = { version = versions.cmake } },
}

The resolved path has to stay inside the calling file's directory. Since envy 0.4.6 one that does not is an error rather than a load from wherever it landed:

envy.loadenv: module '/tmp/outside/helper' resolves outside the calling file's directory: /tmp/outside/helper.lua

To compose one manifest from another, use envy.import instead. loadenv hands back a plain table, so an imported entry's relative source paths would resolve against the wrong directory and its bundle aliases would not resolve at all. Both are silent, and both are what envy.import exists to fix.

To reach a helper inside a bundle, use envy.loadenv_bundle instead. loadenv only ever sees files beside the caller, and a manifest's neighbors are the project's own files.

Options​

envy.options(schema)​

Validates the current options inside an OPTIONS function. Throws on failure, and also rejects options the schema does not declare.

OPTIONS = function(opts)
envy.options({
version = { required = true, type = "semver" },
jobs = { type = "int", range = ">=1 <=64" },
variant = { choices = { "release", "debug" } },
})
end

Constraint keys are required, type (string, int, float, boolean, table, list, semver), range, choices, and validate. The declarative table form of OPTIONS takes the same constraints without the function wrapper. See Options.

See also​