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
+30
View File
@@ -0,0 +1,30 @@
# Archives
*.tar.gz
*.tar.xz
*.tar.bz2
*.tar.zst
*.tgz
*.txz
*.tbz2
*.tzst
*.tar
# Compiled Lua
*.luac
# OS
.DS_Store
Thumbs.db
# Editor
*.swp
*.swo
*~
.vscode/
.idea/
# OpenCode internal state
.omo/
# Build output when testing locally
toolchain/packages/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Axel J. Hinkley
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+62
View File
@@ -0,0 +1,62 @@
# Zeta Toolchain
Package management for [Zerene OS](https://github.com/gretagen). Two tools:
| Tool | What |
|---|---|
| `zeta-makepkg` | Build engine: `.recipe` → binary tarball + `package.lua` |
| `zeta-cli` | Interactive wizard: prompts → `.recipe` file |
## Quick Start
```sh
# Create a recipe interactively
./cli/zeta-cli
# Build it
./builder/zeta-makepkg mypkg.recipe --output ~/zeta-packages
# Install system-wide
make install
```
## Recipe Format
Declarative Lua — no functions, just data:
```lua
return {
name = "mypkg",
version = "1.0",
summary = "Short description",
url = "https://.../src.tar.gz",
deps = { "libfoo", "libbar>=2.0" },
build_system = "autotools", -- autotools | cmake | meson | make | cargo | custom
configure_args = { "--enable-x" },
test = "${DESTDIR}/usr/bin/mypkg --version",
}
```
**Build systems**: `autotools`, `cmake`, `meson`, `make`, `cargo`, `custom`
## Structure
```
toolchain/
├── cli/ zeta-cli — recipe wizard
├── builder/ zeta-makepkg — build engine
├── lib/ shared Lua modules
├── examples/ example .recipe files
└── Makefile
```
## Requirements
- Lua 5.1+
- `curl` or `wget` (source downloads)
- `tar`, `sha256sum` (packaging)
- Build tools for your chosen `build_system` (gcc, meson, cmake, cargo, etc.)
## License
MIT
+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