First Commit

This commit is contained in:
2026-08-06 12:54:48 -04:00
commit dd01ccb258
15 changed files with 1780 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
.PHONY: all install clean
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
LIBDIR ?= $(PREFIX)/lib/zeta-toolchain
all:
@echo "zeta-toolchain: nothing to compile (Lua scripts)"
@echo " make install → $(BINDIR) + $(LIBDIR)"
install:
install -Dm755 cli/zeta-cli $(DESTDIR)$(BINDIR)/zeta-cli
install -Dm755 builder/zeta-makepkg $(DESTDIR)$(BINDIR)/zeta-makepkg
install -d $(DESTDIR)$(LIBDIR)
cp -r lib/* $(DESTDIR)$(LIBDIR)
clean:
rm -rf /tmp/zeta-makepkg
.DEFAULT_GOAL := all
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env lua
-- zeta-makepkg — build Zeta binary packages from declarative .recipe files.
--
-- Usage:
-- zeta-makepkg <recipe> [flags] Build a package from a recipe
-- zeta-makepkg --index [flags] Regenerate packages/index.lua
--
-- Flags:
-- --output <dir> Output repository root (default: .)
-- --no-index Skip index.lua update
-- --keep-work Preserve build work directory
-- --force Overwrite existing package
-- --help Show this help
--
-- Pipeline:
-- LOAD recipe → FETCH source → EXTRACT → BUILD → TEST → PACKAGE → INDEX
-- Lua 5.1 compat: arg[0] is the script path, not the interpreter.
local here
if arg and arg[0] and arg[0]:match("[/\\]") then
here = arg[0]:match("^(.*)[/\\][^/\\]*$")
elseif arg and arg[-1] then
-- Lua 5.1 sends script path as arg[-1] when using env.
here = arg[-1]:match("^(.*)[/\\][^/\\]*$")
else
here = "."
end
-- Resolve the real directory in case we were invoked via a symlink.
if here then
local f = io.popen("cd " .. here:gsub("'", "'\\''") .. " 2>/dev/null && pwd")
if f then
local real = f:read("*l")
f:close()
if real and real ~= "" then here = real end
end
else
here = "."
end
-- Library path: works both from the repo tree (../lib/) and when installed
-- via 'make install' (/usr/local/lib/zeta-toolchain/).
package.path = here .. "/../lib/?.lua;"
.. here .. "/../lib/zeta-toolchain/?.lua;"
.. package.path
local recipe = require("recipe")
local builder = require("builder")
local packager = require("packager")
local indexer = require("indexer")
local path = require("path")
local log = require("log")
---------------------------------------------------------------------------
-- Help
---------------------------------------------------------------------------
local HELP = [[
zeta-makepkg — build Zeta binary packages from declarative .recipe files.
Usage:
zeta-makepkg <recipe> [flags]
zeta-makepkg --index [flags]
Commands:
<recipe> Build a package from a .recipe file
--index Regenerate packages/index.lua from existing package.lua files
Flags:
--output <dir> Output repository root (default: .)
Creates packages/<name>/ with tarball + package.lua
-j, --jobs <N> Number of parallel build jobs (default: nproc)
--no-index Don't update packages/index.lua after building
--keep-work Don't remove the build work directory
--force Overwrite existing package tarball
--help Show this help
Cache:
Downloaded sources are cached in ~/.cache/zeta-makepkg/sources/
Build work is done in /tmp/zeta-makepkg/<name>-<timestamp>/
Examples:
zeta-makepkg mypkg.recipe
zeta-makepkg mypkg.recipe --output ~/zeta-packages --force
zeta-makepkg --index --output ~/zeta-packages
]]
---------------------------------------------------------------------------
-- CLI parsing
---------------------------------------------------------------------------
local function parse_cli(args)
local opts = {
output = ".",
no_index = false,
keep_work = false,
force = false,
index = false,
help = false,
recipe = nil,
jobs = nil, -- nil = auto-detect (nproc)
}
local i = 1
while i <= #args do
local a = args[i]
if a == "--help" or a == "-h" then
opts.help = true
elseif a == "--index" then
opts.index = true
elseif a == "--output" then
i = i + 1
if i > #args then
io.stderr:write("error: --output requires a directory argument\n")
return nil
end
opts.output = args[i]
elseif a == "--no-index" then
opts.no_index = true
elseif a == "--keep-work" then
opts.keep_work = true
elseif a == "--force" then
opts.force = true
elseif a == "--jobs" then
i = i + 1
if i > #args then
io.stderr:write("error: --jobs requires a number argument\n")
return nil
end
local n = tonumber(args[i])
if not n or n < 1 then
io.stderr:write(("error: invalid jobs value %q\n"):format(args[i]))
return nil
end
opts.jobs = n
elseif a:match("^%-j%d+$") then
local n = tonumber(a:match("^%-j(%d+)$"))
opts.jobs = n
elseif a == "-j" then
i = i + 1
if i > #args then
io.stderr:write("error: -j requires a number argument\n")
return nil
end
local n = tonumber(args[i])
if not n or n < 1 then
io.stderr:write(("error: invalid jobs value %q\n"):format(args[i]))
return nil
end
opts.jobs = n
elseif a:match("^%-") then
io.stderr:write(("error: unknown flag %s\n"):format(a))
return nil
elseif not opts.recipe and not opts.index then
opts.recipe = a
else
io.stderr:write(("error: unexpected argument %s\n"):format(a))
return nil
end
i = i + 1
end
return opts
end
---------------------------------------------------------------------------
-- Build pipeline
---------------------------------------------------------------------------
local function run_build(recipe_path, opts)
-- 1. Load recipe
local r, err = recipe.load(recipe_path)
if not r then
log.fatal(("recipe error: %s"):format(tostring(err)))
end
-- 2. Set up output directory
local packages_dir = path.join(opts.output, "packages")
local pkg_dir = path.join(packages_dir, r.name)
if path.exists(pkg_dir) and not opts.force then
log.fatal(("package %s already exists at %s (use --force to overwrite)"):format(
r.name, pkg_dir))
end
path.mkdir_p(pkg_dir)
-- 3. Work directories
local stamp = os.date("%Y%m%d-%H%M%S")
local work_dir = path.join("/tmp/zeta-makepkg", r.name .. "-" .. stamp)
local stage_dir = path.join(work_dir, "stage")
path.mkdir_p(stage_dir)
log.info(("work directory: %s"):format(work_dir))
-- 4. Cache directory
local cache_dir = path.join(os.getenv("HOME") or "/tmp", ".cache", "zeta-makepkg", "sources")
-- 5. Fetch source
local cache_file = builder.fetch_source(r, cache_dir)
-- 6. Extract source
local source_dir = builder.extract_source(r, cache_file, work_dir)
-- 7. Build
builder.run_build(r, source_dir, stage_dir, { jobs = opts.jobs })
-- 8. Test (optional)
if r.test then
builder.run_test(r.test, stage_dir)
else
log.info("no test command — skipping verification")
end
-- 9. Package
local tarball_path = path.join(pkg_dir, r.name .. "-" .. r.version .. ".tar.gz")
local tarball_sha256 = packager.create_tarball(stage_dir, tarball_path)
-- 10. Write package.lua manifest
packager.write_manifest(r, tarball_sha256, pkg_dir)
-- 11. Update index
if not opts.no_index then
indexer.generate(packages_dir)
else
log.info("--no-index: skipping index update")
end
-- 12. Cleanup
if not opts.keep_work then
path.run("rm -rf " .. path.quote(work_dir))
else
log.info(("--keep-work: work directory preserved at %s"):format(work_dir))
end
log.ok(("%s-%s built successfully"):format(r.name, r.version))
log.info(("output: %s"):format(pkg_dir))
end
local function run_index(opts)
local packages_dir = path.join(opts.output, "packages")
indexer.generate(packages_dir)
end
---------------------------------------------------------------------------
-- Main
---------------------------------------------------------------------------
local args = {}
for i = 1, #arg do args[i] = arg[i] end
local opts = parse_cli(args)
if not opts then
io.stderr:write("Run 'zeta-makepkg --help' for usage.\n")
os.exit(1)
end
if opts.help then
io.write(HELP)
os.exit(0)
end
if opts.index then
run_index(opts)
elseif opts.recipe then
local ok, err = pcall(run_build, opts.recipe, opts)
if not ok then
log.fatal(tostring(err))
end
else
io.stderr:write("error: no recipe specified\n")
io.stderr:write("Run 'zeta-makepkg --help' for usage.\n")
os.exit(1)
end
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env lua
-- zeta-cli — interactive recipe creation wizard for zeta-makepkg.
--
-- Walks through every recipe field with prompts, validation, and
-- smart defaults. Writes a ready-to-use .recipe file.
local here
if arg and arg[0] and arg[0]:match("[/\\]") then
here = arg[0]:match("^(.*)[/\\][^/\\]*$")
elseif arg and arg[-1] then
here = arg[-1]:match("^(.*)[/\\][^/\\]*$")
else
here = "."
end
if here then
local f = io.popen("cd " .. here:gsub("'", "'\\''") .. " 2>/dev/null && pwd")
if f then local real = f:read("*l"); f:close(); if real and real ~= "" then here = real end end
end
-- Library path: works both from the repo tree (../lib/) and when installed
-- via 'make install' (/usr/local/lib/zeta-toolchain/).
package.path = here .. "/../lib/?.lua;"
.. here .. "/../lib/zeta-toolchain/?.lua;"
.. package.path
local recipe = require("recipe")
local path = require("path")
---------------------------------------------------------------------------
-- Terminal helpers
---------------------------------------------------------------------------
local function bold(s) return "\27[1m" .. s .. "\27[0m" end
local function dim(s) return "\27[2m" .. s .. "\27[0m" end
local function green(s) return "\27[32m" .. s .. "\27[0m" end
local function red(s) return "\27[31m" .. s .. "\27[0m" end
local function cyan(s) return "\27[36m" .. s .. "\27[0m" end
local function prompt(label, default, validator)
local suffix = ""
if default and default ~= "" then
suffix = dim(" [" .. default .. "]")
end
io.write(label .. suffix .. ": ")
io.flush()
local line = io.read("*l")
if not line then
io.write("\n")
os.exit(0)
end
line = line:gsub("^%s+", ""):gsub("%s+$", "")
if line == "" and default then line = default end
if validator then
local ok, err = validator(line)
if not ok then
io.write(red(" " .. err .. "\n"))
return prompt(label, default, validator)
end
end
return line
end
local function confirm(msg)
io.write(msg .. " [Y/n] ")
io.flush()
local line = io.read("*l")
if not line then io.write("\n"); os.exit(0) end
line = line:gsub("^%s+", ""):gsub("%s+$", ""):lower()
return line == "" or line == "y" or line == "yes"
end
local function choose(label, options)
io.write(bold(label) .. "\n")
for i, opt in ipairs(options) do
io.write(string.format(" %d) %s\n", i, opt))
end
io.write(" Choice [1]: ")
io.flush()
local line = io.read("*l")
if not line then io.write("\n"); os.exit(0) end
line = line:gsub("^%s+", ""):gsub("%s+$", "")
local n = tonumber(line)
if not n or n < 1 or n > #options then n = 1 end
return options[n]
end
---------------------------------------------------------------------------
-- Field validators
---------------------------------------------------------------------------
local function valid_name(name)
if name == "" then return false, "name cannot be empty" end
if not path.sanitize_name(name) then
return false, "name must contain only alphanumeric characters, dots, underscores, plus, and hyphens"
end
return true
end
local function valid_version(v)
if v == "" then return false, "version cannot be empty" end
return true
end
local function valid_summary(s)
if s == "" then return false, "summary cannot be empty" end
return true
end
local function valid_url(u)
if u == "" then return false, "url cannot be empty" end
return true
end
local function valid_sha256(h)
if h == "" then return true end -- ok to skip
if not h:lower():match("^" .. string.rep("%x", 64) .. "$") then
return false, "sha256 must be exactly 64 hex characters (or press Enter to skip)"
end
return true
end
local function valid_jobs(j)
if j == "" then return true end
local n = tonumber(j)
if not n or n < 1 or n % 1 ~= 0 then
return false, "jobs must be a positive integer (or press Enter to skip)"
end
return true
end
---------------------------------------------------------------------------
-- Render the recipe as Lua source
---------------------------------------------------------------------------
local function render(rcp)
local lines = { "return {" }
local function e(s) return s:gsub('"', '\\"'):gsub("\n", "\\n") end
lines[#lines + 1] = string.format(' name = "%s",', e(rcp.name))
lines[#lines + 1] = string.format(' version = "%s",', e(rcp.version))
lines[#lines + 1] = string.format(' summary = "%s",', e(rcp.summary))
lines[#lines + 1] = string.format(' url = "%s",', e(rcp.url))
if rcp.sha256 and rcp.sha256 ~= "" then
lines[#lines + 1] = string.format(' sha256 = "%s",', rcp.sha256)
else
lines[#lines + 1] = " sha256 = nil,"
end
if #rcp.deps > 0 then
local d = {}
for _, dep in ipairs(rcp.deps) do d[#d+1] = '"' .. e(dep) .. '"' end
lines[#lines + 1] = string.format(" deps = { %s },", table.concat(d, ", "))
else
lines[#lines + 1] = " deps = {},"
end
lines[#lines + 1] = string.format(' build_system = "%s",', rcp.build_system)
if rcp.configure_args and #rcp.configure_args > 0 then
local a = {}
for _, arg in ipairs(rcp.configure_args) do a[#a+1] = '"' .. e(arg) .. '"' end
lines[#lines + 1] = string.format(" configure_args = { %s },", table.concat(a, ", "))
end
if rcp.build_system == "custom" then
lines[#lines + 1] = string.format(' build_script = "%s",', e(rcp.build_script or "build.sh"))
end
if rcp.test and rcp.test ~= "" then
lines[#lines + 1] = string.format(' test = "%s",', e(rcp.test):gsub("${DESTDIR}", "${DESTDIR}"))
end
if rcp.files and #rcp.files > 0 then
local f = {}
for _, file in ipairs(rcp.files) do f[#f+1] = '"' .. e(file) .. '"' end
lines[#lines + 1] = string.format(" files = { %s },", table.concat(f, ", "))
end
lines[#lines + 1] = "}"
return table.concat(lines, "\n") .. "\n"
end
---------------------------------------------------------------------------
-- Wizard
---------------------------------------------------------------------------
local function wizard()
io.write("\n")
io.write(bold(" ═══ zeta-makepkg recipe wizard ═══") .. "\n\n")
local rcp = {}
-- 1. Name
rcp.name = prompt("Package name", nil, valid_name)
-- 2. Version
rcp.version = prompt("Version", nil, valid_version)
-- 3. Summary
rcp.summary = prompt("Summary", nil, valid_summary)
-- 4. URL
rcp.url = prompt("Source URL", nil, valid_url)
-- 5. SHA256
rcp.sha256 = prompt("Source SHA256", "", valid_sha256)
-- 6. Dependencies
io.write("Dependencies" .. dim(" [comma-separated, e.g. libfoo, libbar>=2.0]") .. ": ")
io.flush()
local deps_line = io.read("*l")
if not deps_line then io.write("\n"); os.exit(0) end
deps_line = deps_line:gsub("^%s+", ""):gsub("%s+$", "")
rcp.deps = {}
if deps_line ~= "" then
for raw in deps_line:gmatch("[^,]+") do
local dep = raw:gsub("^%s+", ""):gsub("%s+$", "")
if dep ~= "" then rcp.deps[#rcp.deps + 1] = dep end
end
end
-- 7. Build system
rcp.build_system = choose("Build system:", {
"autotools (./configure && make && make install)",
"cmake (cmake -B build && cmake --build && cmake --install)",
"meson (meson setup build && ninja && ninja install)",
"make (make && make install)",
"cargo (cargo build --release)",
"custom (run build_script)",
})
-- Map the selected string back to the key
rcp.build_system = rcp.build_system:match("^(%S+)")
-- 8. Configure args (skip for cargo)
rcp.configure_args = {}
if rcp.build_system ~= "cargo" then
io.write("Configure args" .. dim(" [comma-separated, e.g. --enable-feature, --without-x]") .. ": ")
io.flush()
local args_line = io.read("*l")
if not args_line then io.write("\n"); os.exit(0) end
args_line = args_line:gsub("^%s+", ""):gsub("%s+$", "")
if args_line ~= "" then
for raw in args_line:gmatch("[^,]+") do
local arg = raw:gsub("^%s+", ""):gsub("%s+$", "")
if arg ~= "" then rcp.configure_args[#rcp.configure_args + 1] = arg end
end
end
end
-- 9. Build script (custom only)
if rcp.build_system == "custom" then
rcp.build_script = prompt("Build script path", "build.sh")
end
-- 10. Test command
io.write("Test command" .. dim(" [${DESTDIR} = install staging dir, Enter to skip]") .. "\n")
io.write(" Example: ${DESTDIR}/usr/bin/" .. rcp.name .. " --version\n")
rcp.test = prompt(" Test", "")
if rcp.test == "" then rcp.test = nil end
-- 11. Files whitelist
io.write("Files whitelist" .. dim(" [comma-separated relative paths, Enter = all files]") .. "\n")
io.write(" Example: usr/bin/" .. rcp.name .. ", usr/lib/lib" .. rcp.name .. ".so\n")
io.write(" Files: ")
io.flush()
local files_line = io.read("*l")
if not files_line then io.write("\n"); os.exit(0) end
files_line = files_line:gsub("^%s+", ""):gsub("%s+$", "")
if files_line ~= "" then
rcp.files = {}
for raw in files_line:gmatch("[^,]+") do
local file = raw:gsub("^%s+", ""):gsub("%s+$", ""):gsub("^/*", "")
if file ~= "" then rcp.files[#rcp.files + 1] = file end
end
end
-- 12. Preview
io.write("\n")
io.write(bold(" ═══ Preview ═══") .. "\n\n")
io.write(render(rcp))
io.write("\n")
if not confirm("Write this recipe?") then
io.write("Aborted.\n")
os.exit(0)
end
-- 13. Output path
local default_path = rcp.name .. ".recipe"
local out_path = prompt("Save as", default_path)
local f, err = io.open(out_path, "w")
if not f then
io.write(red("Error: ") .. tostring(err) .. "\n")
os.exit(1)
end
f:write(render(rcp))
f:close()
-- Final validation pass
local ok, verr = pcall(recipe.load, out_path)
if not ok then
io.write(red("Warning: generated recipe fails validation: ") .. tostring(verr) .. "\n")
end
io.write("\n" .. green(" recipe written to " .. out_path) .. "\n")
end
---------------------------------------------------------------------------
-- Main
---------------------------------------------------------------------------
local args = {}
for i = 1, #arg do args[i] = arg[i] end
local ok, err = pcall(wizard)
if not ok then
io.write(red("Error: ") .. tostring(err) .. "\n")
os.exit(1)
end
+16
View File
@@ -0,0 +1,16 @@
-- hello.recipe — minimal example for zeta-makepkg.
--
-- This builds a simple C program using the Make build system.
-- Place a hello-1.0.tar.gz alongside the recipe or point `url` to
-- a remote location.
return {
name = "hello",
version = "1.0",
summary = "A tiny demonstration program for Zeta",
url = "hello-1.0.tar.gz",
sha256 = nil,
deps = {},
build_system = "make",
test = "test -f ${DESTDIR}/usr/bin/hello",
}
+16
View File
@@ -0,0 +1,16 @@
-- make.recipe — builds GNU Make itself.
--
-- GNU Make uses the autotools build system. The --without-guile flag
-- avoids pulling in the Guile dependency for a leaner build.
return {
name = "make",
version = "4.4.1",
summary = "GNU Make — build automation tool",
url = "https://ftp.gnu.org/gnu/make/make-4.4.1.tar.gz",
sha256 = nil,
deps = {},
build_system = "autotools",
configure_args = { "--without-guile" },
test = "${DESTDIR}/usr/bin/make --version",
}
+16
View File
@@ -0,0 +1,16 @@
-- meson-example.recipe — meson build system example.
--
-- Demonstrates: meson, configure_args, dependencies, source sha256.
return {
name = "my-lib",
version = "2.0.0",
summary = "An example library built with Meson",
url = "https://example.com/my-lib-2.0.0.tar.gz",
sha256 = nil,
deps = { "libfoo>=1.0", "libbar" },
build_system = "meson",
configure_args = { "-Ddocs=false", "-Dtests=false" },
test = "test -f ${DESTDIR}/usr/lib/libmy-lib.so",
files = { "usr/lib/libmy-lib.so", "usr/include/my-lib.h" },
}
+307
View File
@@ -0,0 +1,307 @@
-- builder.lua -- source fetching, extraction, build dispatch, and test runner.
--
-- Handles the full build pipeline for a recipe:
-- 1. fetch_source — download to cache (or detect git)
-- 2. extract_source — unpack tarball or clone git repo
-- 3. run_build — dispatch autotools/cmake/meson/make/cargo/custom
-- 4. run_test — execute post-build verification command
--
-- All subprocesses run in a minimal environment. Build tools (meson, cmake,
-- ninja, cargo, etc.) must be installed on the host system.
local builder = {}
local path = require("path")
local log = require("log")
---------------------------------------------------------------------------
-- Utilities
---------------------------------------------------------------------------
local function find_downloader()
if path.have("curl") then return "curl" end
if path.have("wget") then return "wget" end
return nil
end
local function download(url, dest)
local dl = find_downloader()
if not dl then
error("no downloader found (install curl or wget)", 0)
end
log.detail("downloading " .. url)
local tmp = dest .. ".part"
local ok
if dl == "curl" then
ok = path.run("curl -L --fail --show-error -sS -o "
.. path.quote(tmp) .. " " .. path.quote(url))
else
ok = path.run("wget -q -O " .. path.quote(tmp) .. " " .. path.quote(url))
end
if not ok then
os.remove(tmp)
error(("download failed: %s"):format(url), 0)
end
return os.rename(tmp, dest)
end
local function sha256_file(filepath)
-- Prefer native tools for speed, fall back to openssl.
local f = io.popen("sha256sum " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local digest = f:read("*l")
f:close()
local hex = digest and digest:match("^(%x+)")
if hex then return hex end
end
f = io.popen("shasum -a 256 " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local digest = f:read("*l")
f:close()
local hex = digest and digest:match("^(%x+)")
if hex then return hex end
end
f = io.popen("openssl dgst -sha256 " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local digest = f:read("*l")
f:close()
local hex = digest and digest:match("=(%x+)")
if hex then return hex end
end
error("sha256: no supported tool found (install sha256sum, shasum, or openssl)", 0)
end
local function get_nproc()
local f = io.popen("nproc 2>/dev/null")
if not f then return 1 end
local n = tonumber(f:read("*l"))
f:close()
return (n and n > 0) and n or 1
end
-- After extracting a tarball, detect the source root:
-- if the extraction dir contains exactly one subdirectory, use that.
-- Otherwise use the extraction dir itself.
local function detect_source_dir(extract_dir)
local f = io.popen("ls -1 " .. path.quote(extract_dir) .. " 2>/dev/null")
if not f then return extract_dir end
local entries = {}
for line in f:lines() do
if line ~= "" and line ~= "." and line ~= ".." then
entries[#entries + 1] = line
end
end
f:close()
if #entries == 1 then
return path.join(extract_dir, entries[1])
end
return extract_dir
end
---------------------------------------------------------------------------
-- Public API
---------------------------------------------------------------------------
-- Fetch source into the cache directory. Returns the cached file path,
-- or nil if the URL is a git repository (signals "use git clone").
function builder.fetch_source(recipe, cache_dir)
local url = recipe.url
-- Git repos are cloned, not cached as files.
if url:match("%.git$") or url:match("^git://") or url:match("^git@") then
log.detail("git source: will clone directly")
return nil
end
local url_base = url:match("[^/]+$") or "source"
local ext = ""
for _, e in ipairs({ ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".tbz2", ".tzst" }) do
if url_base:lower():match(e:gsub("%.", "%%.") .. "$") then
ext = e
break
end
end
local cache_file = path.join(cache_dir, recipe.name .. "-" .. recipe.version .. ext)
if not path.exists(cache_file) then
path.mkdir_p(cache_dir)
if url:match("^https?://") then
log.step("downloading " .. url)
download(url, cache_file)
elseif url:match("^file://") then
local src = url:gsub("^file://", "")
log.step("copying " .. src)
if not path.run("cp " .. path.quote(src) .. " " .. path.quote(cache_file)) then
error(("failed to copy %s"):format(src), 0)
end
elseif url:match("^/") then
log.step("copying " .. url)
if not path.run("cp " .. path.quote(url) .. " " .. path.quote(cache_file)) then
error(("failed to copy %s"):format(url), 0)
end
else
error(("unrecognised url scheme: %s"):format(url), 0)
end
else
log.info("using cached source: " .. path.basename(cache_file))
end
-- Optional source checksum verification.
if recipe.sha256 then
log.step("verifying source sha256")
local computed = sha256_file(cache_file)
if computed ~= recipe.sha256 then
error(("source sha256 mismatch:\n expected %s\n got %s"):format(
recipe.sha256, computed), 0)
end
log.ok("source sha256 verified")
end
return cache_file
end
-- Extract a source tarball or clone a git repository into work_dir/src.
-- Returns the actual source root directory.
function builder.extract_source(recipe, cache_file, work_dir)
local src_dir = path.join(work_dir, "src")
if not cache_file then
-- Git clone.
path.mkdir_p(work_dir)
log.step("cloning " .. recipe.url)
if not path.run("git clone --depth 1 " .. path.quote(recipe.url)
.. " " .. path.quote(src_dir)) then
error(("git clone failed for %s"):format(recipe.url), 0)
end
log.ok("cloned " .. recipe.name)
return src_dir
end
-- Tarball extraction.
log.step("extracting " .. path.basename(cache_file))
path.mkdir_p(src_dir)
local fname = cache_file:lower()
local cmd
if fname:match("%.tar%.gz$") or fname:match("%.tgz$") then
cmd = "tar -xzf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar%.xz$") or fname:match("%.txz$") then
cmd = "tar -xJf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar%.bz2$") or fname:match("%.tbz2$") then
cmd = "tar -xjf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar%.zst$") or fname:match("%.tzst$") then
cmd = "tar --zstd -xf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar$") then
cmd = "tar -xf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
else
-- Unrecognised archive — try generic tar.
cmd = "tar -xf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
end
if not path.run(cmd) then
error(("extraction failed for %s"):format(path.basename(cache_file)), 0)
end
local source_dir = detect_source_dir(src_dir)
log.detail("source directory: " .. source_dir)
return source_dir
end
-- Dispatch the build based on recipe.build_system.
-- Commands run from the source directory, installing into stage_dir.
function builder.run_build(recipe, source_dir, stage_dir, opts)
opts = opts or {}
path.mkdir_p(stage_dir)
local name = recipe.name
local bs = recipe.build_system
local args = recipe.configure_args or {}
local args_str = ""
if #args > 0 then
args_str = " " .. table.concat(args, " ")
end
local njobs = opts.jobs or get_nproc()
log.step(("building %s (%s)"):format(name, bs))
local function run(cmd)
log.detail("$ " .. cmd)
if not path.run("cd " .. path.quote(source_dir) .. " && " .. cmd) then
error(("build step failed: %s"):format(cmd), 0)
end
end
if bs == "autotools" then
run("./configure --prefix=/usr" .. args_str)
run("make -j" .. njobs)
run("make install DESTDIR=" .. path.quote(stage_dir))
elseif bs == "cmake" then
run("cmake -B build -DCMAKE_INSTALL_PREFIX=/usr" .. args_str)
run("cmake --build build -j" .. njobs)
run("DESTDIR=" .. path.quote(stage_dir) .. " cmake --install build")
elseif bs == "meson" then
run("meson setup build --prefix=/usr" .. args_str)
run("ninja -C build")
run("DESTDIR=" .. path.quote(stage_dir) .. " ninja -C build install")
elseif bs == "make" then
run("make -j" .. njobs .. args_str)
run("make install DESTDIR=" .. path.quote(stage_dir))
elseif bs == "cargo" then
run("cargo build --release" .. args_str)
local bin_dir = path.join(stage_dir, "usr/bin")
path.mkdir_p(bin_dir)
-- Cargo puts binaries in target/release/<name>. Also support kebab-case.
local target = path.join(source_dir, "target", "release", name)
if not path.exists(target) then
-- Try kebab-case transformation: underscores → hyphens.
local kebab = name:gsub("_", "-")
target = path.join(source_dir, "target", "release", kebab)
end
if not path.exists(target) then
-- Try listing directory.
local f = io.popen("ls " .. path.quote(path.join(source_dir, "target", "release")) .. " 2>/dev/null")
if f then
for line in f:lines() do
local cand = path.join(source_dir, "target", "release", line)
if path.run("test -f " .. path.quote(cand) .. " -a -x " .. path.quote(cand)) then
target = cand
log.detail("detected cargo binary: " .. line)
break
end
end
f:close()
end
end
run("install -D " .. path.quote(target) .. " " .. path.quote(path.join(bin_dir, path.basename(target))))
elseif bs == "custom" then
local script = recipe.build_script
run("DESTDIR=" .. path.quote(stage_dir) .. " sh " .. path.quote(script))
else
error(("unknown build_system: %s"):format(bs), 0)
end
log.ok("build complete")
return true
end
-- Run the test command, substituting ${DESTDIR} with stage_dir.
function builder.run_test(test_cmd, stage_dir)
local cmd = test_cmd:gsub("${DESTDIR}", stage_dir)
cmd = cmd:gsub("$DESTDIR", stage_dir)
log.step("testing: " .. cmd)
if not path.run(cmd) then
error(("test failed: %s"):format(cmd), 0)
end
log.ok("test passed")
return true
end
return builder
+120
View File
@@ -0,0 +1,120 @@
-- indexer.lua -- generates packages/index.lua from all package.lua manifests.
--
-- Scans <output>/packages/ for every subdirectory containing a package.lua.
-- Each manifest is loaded in a sandboxed environment (no io, os, etc.) and
-- its name, version, and summary are extracted. The resulting index is
-- written as a Lua list, sorted by package name.
local indexer = {}
local path = require("path")
local log = require("log")
---------------------------------------------------------------------------
-- Sandboxed manifest loading
---------------------------------------------------------------------------
-- Load a package.lua file and extract { name, version, summary }.
-- Runs in a locked-down environment — the manifest cannot access io, os,
-- require, or any global state. Only pure table constructors are allowed.
local function load_manifest_meta(filepath)
local f, err = io.open(filepath, "rb")
if not f then return nil, err end
local src = f:read("*a")
f:close()
local env = {}
local chunk
if setfenv then
chunk, err = loadstring(src, "@" .. filepath)
if chunk then setfenv(chunk, env) end
else
chunk, err = load(src, "@" .. filepath, "t", env)
end
if not chunk then
return nil, ("%s: syntax error: %s"):format(filepath, tostring(err))
end
local ok, raw = pcall(chunk)
if not ok or type(raw) ~= "table" then
return nil, ("%s: must return a table"):format(filepath)
end
local meta = {
name = type(raw.name) == "string" and raw.name or nil,
version = type(raw.version) == "string" and raw.version or nil,
summary = type(raw.summary) == "string" and raw.summary or nil,
}
if not meta.name then
return nil, ("%s: missing or invalid name"):format(filepath)
end
return meta
end
---------------------------------------------------------------------------
-- Index generation
---------------------------------------------------------------------------
-- Scan <packages_dir> for all subdirectories containing package.lua and
-- write a sorted index.lua. Returns the path to the written index.
function indexer.generate(packages_dir)
if not path.exists(packages_dir) then
log.warn("packages directory not found: " .. packages_dir)
return nil
end
log.step("generating index for " .. packages_dir)
-- Collect all package.lua files.
local f = io.popen("find " .. path.quote(packages_dir)
.. " -mindepth 2 -maxdepth 2 -name package.lua"
.. " -type f 2>/dev/null | sort")
if not f then
error(("cannot scan %s"):format(packages_dir), 0)
end
local entries = {}
for filepath in f:lines() do
local meta, err = load_manifest_meta(filepath)
if meta then
entries[#entries + 1] = meta
else
log.warn(tostring(err))
end
end
f:close()
-- Sort by name.
table.sort(entries, function(a, b) return a.name < b.name end)
-- Write index.lua
local index_path = path.join(packages_dir, "index.lua")
local lines = { "return {" }
for _, e in ipairs(entries) do
lines[#lines + 1] = string.format(
' { name = %q, version = %q, summary = %q },',
e.name,
e.version or "",
e.summary or ""
)
end
lines[#lines + 1] = "}"
local tmp = index_path .. ".tmp"
local out, err = io.open(tmp, "wb")
if not out then
error(("cannot write %s: %s"):format(tmp, tostring(err)), 0)
end
out:write(table.concat(lines, "\n") .. "\n")
out:close()
os.rename(tmp, index_path)
log.ok(("index updated (%d packages)"):format(#entries))
return index_path
end
return indexer
+60
View File
@@ -0,0 +1,60 @@
-- log.lua -- always-verbose colored logging for zeta-makepkg.
--
-- Every operation is printed so the user sees exactly what is happening.
-- Colors are disabled when TERM=dumb or NO_COLOR is set.
local log = {}
local function wants_color()
local t = os.getenv("TERM")
if not t or t == "" or t == "dumb" then return false end
if os.getenv("NO_COLOR") then return false end
return true
end
local COLOR = wants_color()
local C = {
reset = "\27[0m",
cyan = "\27[36m",
green = "\27[32m",
yellow = "\27[33m",
red = "\27[31m",
dim = "\27[2m",
}
local function paint(color, s)
if not COLOR then return s end
return C[color] .. s .. C.reset
end
function log.step(msg)
print(paint("cyan", "==> " .. msg))
end
function log.ok(msg)
print(paint("green", " ok " .. msg))
end
function log.warn(msg)
io.stderr:write(paint("yellow", "warn ") .. msg .. "\n")
end
function log.error(msg)
io.stderr:write(paint("red", "error") .. " " .. msg .. "\n")
end
function log.info(msg)
print(" - " .. msg)
end
function log.detail(msg)
print(paint("dim", " . " .. msg))
end
function log.fatal(msg)
log.error(msg)
os.exit(1)
end
return log
+167
View File
@@ -0,0 +1,167 @@
-- packager.lua -- tarball creation, sha256 computation, and package.lua generation.
--
-- Converts a staged install tree (DESTDIR layout) into a Zeta-compatible
-- binary tarball with archive strip=1 format. Computes the sha256 checksum
-- of the output tarball and writes the package.lua manifest.
local packager = {}
local path = require("path")
local log = require("log")
---------------------------------------------------------------------------
-- Private helpers (must be defined before the public API that uses them)
---------------------------------------------------------------------------
local function escape(s)
return s:gsub('"', '\\"'):gsub("\n", "\\n")
end
local function escape_lua_string(s)
return s:gsub("\\", "\\\\"):gsub("'", "\\'")
end
local function human_size(filepath)
local f = io.open(filepath, "rb")
if not f then return "? B" end
local size = f:seek("end")
f:close()
if size >= 1024 * 1024 then
return string.format("%.1f MiB", size / (1024 * 1024))
elseif size >= 1024 then
return string.format("%.0f KiB", size / 1024)
end
return string.format("%d B", size)
end
local function sha256_file(filepath)
local f = io.popen("sha256sum " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local line = f:read("*l")
f:close()
local hex = line and line:match("^(%x+)")
if hex then return hex end
end
f = io.popen("shasum -a 256 " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local line = f:read("*l")
f:close()
local hex = line and line:match("^(%x+)")
if hex then return hex end
end
f = io.popen("openssl dgst -sha256 " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local line = f:read("*l")
f:close()
local hex = line and line:match("=(%x+)")
if hex then return hex end
end
error("sha256: no supported tool found (install sha256sum, shasum, or openssl)", 0)
end
local function write_atomic(filepath, content)
local tmp = filepath .. ".tmp"
local f, err = io.open(tmp, "wb")
if not f then return nil, err end
f:write(content)
f:close()
return os.rename(tmp, filepath)
end
---------------------------------------------------------------------------
-- Public API
---------------------------------------------------------------------------
-- Tar up the staging directory, strip=1 format.
-- Files in the tarball are rooted at "./" so Zeta's strip=1 produces clean
-- relative paths (usr/bin/foo, not ./usr/bin/foo).
function packager.create_tarball(stage_dir, dest_path)
log.step("packaging " .. path.basename(dest_path))
path.mkdir_p(path.dirname(dest_path))
local cmd = "tar -czf " .. path.quote(dest_path)
.. " -C " .. path.quote(stage_dir) .. " ."
if not path.run(cmd) then
error(("failed to create tarball %s"):format(dest_path), 0)
end
local sha256 = sha256_file(dest_path)
log.detail(("sha256: %s"):format(sha256))
log.ok(("packaged %s (%s)"):format(path.basename(dest_path), human_size(dest_path)))
return sha256
end
-- Generate a Zeta-compatible package.lua manifest and write it to pkg_dir.
-- The generated manifest uses archive mode (strip=1) and includes the
-- computed sha256 of the binary tarball. Optional fields (deps, files,
-- test) are included when present in the recipe.
function packager.write_manifest(recipe, tarball_sha256, pkg_dir)
local lines = {}
local function emit(fmt, ...)
lines[#lines + 1] = string.format(fmt, ...)
end
emit("return {")
emit(' name = "%s",', recipe.name)
emit(' version = "%s",', recipe.version)
emit(' summary = "%s",', escape(recipe.summary))
emit(' url = "https://github.com/gretagen/zeta-packages/packages/%s/%s-%s.tar.gz",',
recipe.name, recipe.name, recipe.version)
emit(' sha256 = "%s",', tarball_sha256)
if #recipe.deps > 0 then
local dep_strs = {}
for _, d in ipairs(recipe.deps) do
dep_strs[#dep_strs + 1] = string.format('"%s"', escape(d))
end
emit(' deps = { %s },', table.concat(dep_strs, ", "))
else
emit(' deps = {},')
end
emit(' archive = { strip = 1 },')
if recipe.files then
local file_strs = {}
for _, f in ipairs(recipe.files) do
file_strs[#file_strs + 1] = string.format('"%s"', escape(f))
end
emit(' files = { %s },', table.concat(file_strs, ", "))
end
if recipe.test then
-- Generate a test function that substitutes ${DESTDIR} -> p.install_root.
-- Splits the test command around each ${DESTDIR} and uses Lua's %q format
-- so every string segment is properly quoted.
emit(" test = function(p)")
local parts = {}
local rem = recipe.test
while true do
local si, ei = rem:find("${DESTDIR}", 1, true)
if not si then
if rem ~= "" then parts[#parts + 1] = string.format("%q", rem) end
break
end
if si > 1 then
parts[#parts + 1] = string.format("%q", rem:sub(1, si - 1))
end
parts[#parts + 1] = "p.install_root"
rem = rem:sub(ei + 1)
end
emit(" p:run(%s)", table.concat(parts, " .. "))
emit(" end,")
end
emit("}")
local content = table.concat(lines, "\n") .. "\n"
local dest = path.join(pkg_dir, "package.lua")
write_atomic(dest, content)
log.ok("wrote package.lua")
return dest
end
return packager
+124
View File
@@ -0,0 +1,124 @@
-- path.lua -- minimal self-contained filesystem helpers for zeta-makepkg.
--
-- Deliberately avoids any external dependencies so the toolchain can run
-- without Zeta installed. All subprocess calls use plain os.execute.
local path = {}
---------------------------------------------------------------------------
-- String helpers
---------------------------------------------------------------------------
-- Join path components with a single separator. The first non-empty
-- component decides whether the result is absolute; later components are
-- treated as plain name pieces even if they carry a leading slash.
function path.join(...)
local parts = {}
local abs = false
local first = true
for i = 1, select("#", ...) do
local part = tostring((select(i, ...)))
if part == "/" then
if first then abs = true end
elseif part ~= "" then
local p = part:gsub("^/+", ""):gsub("/+$", "")
if p ~= "" then
if first then abs = (part:match("^/") ~= nil) end
parts[#parts + 1] = p
end
end
first = false
end
if #parts == 0 then return "/" end
local out = table.concat(parts, "/")
if abs then out = "/" .. out end
return out
end
function path.is_abs(p)
return p:match("^/") ~= nil
end
function path.basename(p)
p = p:gsub("/+$", "")
return p:match("[^/]+$") or p
end
function path.dirname(p)
p = p:gsub("/+$", "")
if p == "" then return "." end
local d = p:match("^(.*)/[^/]+$")
if not d then return "." end
if d == "" then return "/" end
return d
end
-- Shell-quote a value for safe use in a command. Values containing only
-- "safe" characters are returned as-is; everything else is single-quoted.
function path.quote(s)
if s == "" then return "''" end
if s:match("^[%w%._%/+%-:=@,]+$") then return s end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
---------------------------------------------------------------------------
-- Filesystem helpers
---------------------------------------------------------------------------
-- Run a shell command, streaming output to the parent. Returns true on
-- exit status 0. Compatible with Lua 5.1 through 5.4.
function path.run(cmd)
local a, b, c = os.execute(cmd)
if type(a) == "number" then return a == 0 end
return a == true and b == "exit" and c == 0
end
-- mkdir -p wrapper.
function path.mkdir_p(dir)
if dir == "" or dir == "/" then return true end
return path.run("mkdir -p " .. path.quote(dir))
end
-- True if a file or directory (or dangling symlink) exists.
function path.exists(p)
local f = io.open(p, "rb")
if f then
f:close()
return true
end
return path.run("test -e " .. path.quote(p) .. " -o -L " .. path.quote(p))
end
-- Package names must be tightly constrained (alphanumeric, dots, underscores,
-- plus, hyphen). No slashes, no leading dots, no other special characters.
function path.sanitize_name(name)
if type(name) ~= "string" then return nil end
if name == "" then return nil end
if name:match("^%.") then return nil end
if not name:match("^[%w%._+%-]+$") then return nil end
return name
end
---------------------------------------------------------------------------
-- Download / tool detection
---------------------------------------------------------------------------
-- True if the given command is available on PATH.
function path.have(cmd)
local f = io.popen("command -v " .. cmd .. " 2>/dev/null")
if not f then return false end
local out = f:read("*l")
f:close()
return out ~= nil and out ~= ""
end
-- Capture stdout of a command. Returns output string or nil on failure.
function path.capture(cmd)
local f = io.popen(cmd .. " 2>/dev/null")
if not f then return nil end
local out = f:read("*a")
f:close()
return out
end
return path
+226
View File
@@ -0,0 +1,226 @@
-- recipe.lua -- loading and validation of declarative .recipe files.
--
-- A recipe is a Lua file returning a table. It is purely data — no functions,
-- no side effects. The recipe describes *what* to build; zeta-makepkg handles
-- the *how*. This keeps recipes readable, reviewable, and safe to execute.
--
-- return {
-- name = "mypkg",
-- version = "1.2.3",
-- summary = "Short description",
-- url = "https://.../mypkg-1.2.3.tar.gz",
-- sha256 = nil, -- optional source checksum
-- deps = { "libfoo" },
-- build_system = "meson", -- autotools | cmake | meson | make | cargo | custom
-- configure_args = { ... }, -- optional extra args
-- build_script = "build.sh", -- required for custom
-- test = "test -f ${DESTDIR}/usr/bin/mypkg",
-- files = { "usr/bin/mypkg" },
-- }
local recipe = {}
local path = require("path")
local log = require("log")
local KNOWN_KEYS = {
name = true, version = true, summary = true, url = true,
sha256 = true, deps = true, build_system = true,
configure_args = true, build_script = true, test = true, files = true,
}
local BUILD_SYSTEMS = {
autotools = true, cmake = true, meson = true,
make = true, cargo = true, custom = true,
}
local HEX64 = "^" .. string.rep("%x", 64) .. "$"
---------------------------------------------------------------------------
-- Normalization & validation
---------------------------------------------------------------------------
function recipe.normalize(raw)
if type(raw) ~= "table" then
error("recipe must return a table", 2)
end
-- name (required, must be a valid package name)
local name = raw.name
if type(name) ~= "string" or not path.sanitize_name(name) then
error("recipe: missing or invalid package name", 2)
end
-- version (required, non-empty)
local version = raw.version
if type(version) ~= "string" or version == "" then
error(("recipe %q: version is required and must be a non-empty string"):format(name), 2)
end
-- summary (required)
local summary = raw.summary
if type(summary) ~= "string" or summary == "" then
error(("recipe %q: summary is required"):format(name), 2)
end
-- url (required, must be a string)
local url = raw.url
if type(url) ~= "string" or url == "" then
error(("recipe %q: url is required"):format(name), 2)
end
-- sha256 (optional, must be 64 hex chars if present)
local sha256 = nil
if raw.sha256 ~= nil then
if type(raw.sha256) ~= "string" or not raw.sha256:lower():match(HEX64) then
error(("recipe %q: sha256 must be exactly 64 hex characters or nil"):format(name), 2)
end
sha256 = raw.sha256:lower()
end
-- deps (optional, list of strings)
local deps = {}
if raw.deps ~= nil then
if type(raw.deps) ~= "table" then
error(("recipe %q: deps must be a list of strings"):format(name), 2)
end
for _, d in ipairs(raw.deps) do
if type(d) ~= "string" or d == "" then
error(("recipe %q: each dep must be a non-empty string"):format(name), 2)
end
deps[#deps + 1] = d
end
end
-- build_system (required, must be valid)
local build_system = raw.build_system
if type(build_system) ~= "string" or not BUILD_SYSTEMS[build_system] then
error(("recipe %q: build_system must be one of: %s"):format(
name, table.concat(sorted_keys(BUILD_SYSTEMS), ", ")), 2)
end
-- configure_args (optional, list of strings)
local configure_args = {}
if raw.configure_args ~= nil then
if type(raw.configure_args) ~= "table" then
error(("recipe %q: configure_args must be a list of strings"):format(name), 2)
end
for _, a in ipairs(raw.configure_args) do
configure_args[#configure_args + 1] = tostring(a)
end
end
-- build_script (required for custom, ignored otherwise)
local build_script = nil
if raw.build_script ~= nil then
if type(raw.build_script) ~= "string" or raw.build_script == "" then
error(("recipe %q: build_script must be a non-empty string"):format(name), 2)
end
build_script = raw.build_script
end
if build_system == "custom" and not build_script then
error(("recipe %q: build_script is required when build_system is 'custom'"):format(name), 2)
end
-- test (optional, shell command string)
local test_cmd = nil
if raw.test ~= nil then
if type(raw.test) ~= "string" or raw.test == "" then
error(("recipe %q: test must be a non-empty shell command string"):format(name), 2)
end
test_cmd = raw.test
end
-- files (optional, whitelist of relative paths)
local files = nil
if raw.files ~= nil then
if type(raw.files) ~= "table" then
error(("recipe %q: files must be a list of relative paths"):format(name), 2)
end
files = {}
for _, f in ipairs(raw.files) do
if type(f) ~= "string" or f == "" then
error(("recipe %q: each files entry must be a non-empty string"):format(name), 2)
end
local rel = f:gsub("^/*", ""):gsub("/+$", "")
if rel == "" then
error(("recipe %q: files entry %q is invalid"):format(name, f), 2)
end
files[#files + 1] = rel
end
end
-- Flag unknown fields
for k in pairs(raw) do
if not KNOWN_KEYS[k] then
log.warn(("recipe %q: unknown field %q (ignored)"):format(name, tostring(k)))
end
end
return {
name = name,
version = version,
summary = summary,
url = url,
sha256 = sha256,
deps = deps,
build_system = build_system,
configure_args = configure_args,
build_script = build_script,
test = test_cmd,
files = files,
}
end
---------------------------------------------------------------------------
-- Loading
---------------------------------------------------------------------------
function recipe.load(filepath)
local f, err = io.open(filepath, "rb")
if not f then
return nil, ("cannot open recipe %q: %s"):format(filepath, tostring(err))
end
local src = f:read("*a")
f:close()
-- Recipes run in a minimal environment — they're pure data, but we still
-- lock down globals to prevent accidental side effects.
local env = {}
local chunk
if setfenv then
chunk, err = loadstring(src, "@" .. filepath)
if chunk then setfenv(chunk, env) end
else
chunk, err = load(src, "@" .. filepath, "t", env)
end
if not chunk then
return nil, ("recipe %q: syntax error: %s"):format(filepath, tostring(err))
end
local ok, raw = pcall(chunk)
if not ok then
return nil, ("recipe %q: runtime error: %s"):format(filepath, tostring(raw))
end
local ok2, normalized = pcall(recipe.normalize, raw)
if not ok2 then
return nil, ("recipe %q: " .. tostring(normalized)):format(filepath)
end
log.ok(("loaded recipe %s-%s"):format(normalized.name, normalized.version))
return normalized
end
---------------------------------------------------------------------------
-- Helpers
---------------------------------------------------------------------------
function sorted_keys(t)
local keys = {}
for k in pairs(t) do keys[#keys + 1] = k end
table.sort(keys)
return keys
end
return recipe