First Commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user