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
| Function | Manifest and spec top level | Phase functions | Notes |
|---|---|---|---|
constants, path.*, template, extend, logging | yes | yes | pure |
abspath, loadenv | yes | yes | resolves against the calling file |
import | manifest top level only | no | not installed in specs or envy lua |
loadenv_bundle | manifest top level only | no | materializes the bundle first. Requires envy 0.4.6. |
copy, move, remove, exists, is_file, is_dir | yes | yes | relative paths anchor to stage_dir in phases |
run, extract, extract_all | yes | yes | |
fetch | no | FETCH only | |
commit_fetch | no | FETCH only | needs the cache lock |
verify_hash | yes | yes | |
package, product, loadenv_spec | no | yes | subject to needed_by |
options | in an OPTIONS function | no |
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
| Constant | Value |
|---|---|
envy.PLATFORM | "darwin", "linux", or "windows" |
envy.ARCH | "arm64" or "x86_64" |
envy.PLATFORM_ARCH | the two joined, for example "darwin-arm64" |
envy.EXE_EXT | ".exe" on Windows, "" elsewhere |
ENVY_SHELL.BASH, .SH, .CMD, .POWERSHELL | shell 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.
| Option | Default | Meaning |
|---|---|---|
cwd | the phase's directory | Working directory. |
env | inherited | Table merged over the inherited environment. |
shell | the platform default or DEFAULT_SHELL | ENVY_SHELL.* or a shell config table. |
quiet | false | Suppress the child's output. |
capture | false | Put stdout and stderr in the result. |
check | true | Throw on a non-zero exit. |
interactive | false | Connect 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?)
| Option | Meaning |
|---|---|
strip | Drop this many leading path components. |
only | Extract just these archive-relative paths or globs, matched after strip. A directory brings its subtree. A leading ! excludes. |
archives | envy.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:
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:
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
sourcepaths resolve against the imported manifest's directory. A component'ssource = "envy/protoc.lua"meanslibs/common/envy/protoc.luano matter who imports it.envy.abspathis no longer needed for this. -
bundle = "alias"resolves against the importedBUNDLESfirst, then the root's. Re-exportingBUNDLESis 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.fetchgets 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:
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
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:
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.
| Field | Value |
|---|---|
identity | The bundle's identity. |
alias | What the calling file called it. nil under envy.loadenv_spec, which resolves by identity. |
root | Absolute path of the bundle's materialized root. |
It saves a helper from being handed a name its caller already typed:
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:
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
- Spec Reference for the globals these functions appear in
- Anatomy of a Spec
- Shells & Scripts