From dd01ccb258cbd07b39589e84bde24e9f7845e820 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Thu, 6 Aug 2026 12:54:48 -0400 Subject: [PATCH] First Commit --- .gitignore | 30 +++ LICENSE | 21 ++ README.md | 62 +++++ toolchain/Makefile | 20 ++ toolchain/builder/zeta-makepkg | 273 ++++++++++++++++++++ toolchain/cli/zeta-cli | 322 ++++++++++++++++++++++++ toolchain/examples/hello.recipe | 16 ++ toolchain/examples/make.recipe | 16 ++ toolchain/examples/meson-example.recipe | 16 ++ toolchain/lib/builder.lua | 307 ++++++++++++++++++++++ toolchain/lib/indexer.lua | 120 +++++++++ toolchain/lib/log.lua | 60 +++++ toolchain/lib/packager.lua | 167 ++++++++++++ toolchain/lib/path.lua | 124 +++++++++ toolchain/lib/recipe.lua | 226 +++++++++++++++++ 15 files changed, 1780 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 toolchain/Makefile create mode 100755 toolchain/builder/zeta-makepkg create mode 100755 toolchain/cli/zeta-cli create mode 100644 toolchain/examples/hello.recipe create mode 100644 toolchain/examples/make.recipe create mode 100644 toolchain/examples/meson-example.recipe create mode 100644 toolchain/lib/builder.lua create mode 100644 toolchain/lib/indexer.lua create mode 100644 toolchain/lib/log.lua create mode 100644 toolchain/lib/packager.lua create mode 100644 toolchain/lib/path.lua create mode 100644 toolchain/lib/recipe.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b6aa09 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5920751 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49008d0 --- /dev/null +++ b/README.md @@ -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 diff --git a/toolchain/Makefile b/toolchain/Makefile new file mode 100644 index 0000000..8cc7c05 --- /dev/null +++ b/toolchain/Makefile @@ -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 diff --git a/toolchain/builder/zeta-makepkg b/toolchain/builder/zeta-makepkg new file mode 100755 index 0000000..95242be --- /dev/null +++ b/toolchain/builder/zeta-makepkg @@ -0,0 +1,273 @@ +#!/usr/bin/env lua +-- zeta-makepkg — build Zeta binary packages from declarative .recipe files. +-- +-- Usage: +-- zeta-makepkg [flags] Build a package from a recipe +-- zeta-makepkg --index [flags] Regenerate packages/index.lua +-- +-- Flags: +-- --output 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 [flags] + zeta-makepkg --index [flags] + +Commands: + Build a package from a .recipe file + --index Regenerate packages/index.lua from existing package.lua files + +Flags: + --output Output repository root (default: .) + Creates packages// with tarball + package.lua + -j, --jobs 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/-/ + +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 diff --git a/toolchain/cli/zeta-cli b/toolchain/cli/zeta-cli new file mode 100755 index 0000000..7a66045 --- /dev/null +++ b/toolchain/cli/zeta-cli @@ -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 diff --git a/toolchain/examples/hello.recipe b/toolchain/examples/hello.recipe new file mode 100644 index 0000000..ec739ea --- /dev/null +++ b/toolchain/examples/hello.recipe @@ -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", +} diff --git a/toolchain/examples/make.recipe b/toolchain/examples/make.recipe new file mode 100644 index 0000000..4a76fc6 --- /dev/null +++ b/toolchain/examples/make.recipe @@ -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", +} diff --git a/toolchain/examples/meson-example.recipe b/toolchain/examples/meson-example.recipe new file mode 100644 index 0000000..81d281e --- /dev/null +++ b/toolchain/examples/meson-example.recipe @@ -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" }, +} diff --git a/toolchain/lib/builder.lua b/toolchain/lib/builder.lua new file mode 100644 index 0000000..9dadb02 --- /dev/null +++ b/toolchain/lib/builder.lua @@ -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/. 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 diff --git a/toolchain/lib/indexer.lua b/toolchain/lib/indexer.lua new file mode 100644 index 0000000..c95f2e7 --- /dev/null +++ b/toolchain/lib/indexer.lua @@ -0,0 +1,120 @@ +-- indexer.lua -- generates packages/index.lua from all package.lua manifests. +-- +-- Scans /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 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 diff --git a/toolchain/lib/log.lua b/toolchain/lib/log.lua new file mode 100644 index 0000000..c5bd2bf --- /dev/null +++ b/toolchain/lib/log.lua @@ -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 diff --git a/toolchain/lib/packager.lua b/toolchain/lib/packager.lua new file mode 100644 index 0000000..6372fe1 --- /dev/null +++ b/toolchain/lib/packager.lua @@ -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 diff --git a/toolchain/lib/path.lua b/toolchain/lib/path.lua new file mode 100644 index 0000000..cf492bf --- /dev/null +++ b/toolchain/lib/path.lua @@ -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 diff --git a/toolchain/lib/recipe.lua b/toolchain/lib/recipe.lua new file mode 100644 index 0000000..137fcd6 --- /dev/null +++ b/toolchain/lib/recipe.lua @@ -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