diff --git a/toolchain/builder/zeta-makepkg b/toolchain/builder/zeta-makepkg index 690f588..8277c61 100755 --- a/toolchain/builder/zeta-makepkg +++ b/toolchain/builder/zeta-makepkg @@ -75,6 +75,8 @@ Flags: -w, --workers Number of parallel package builds for --all (default: 1) --no-index Don't update packages/index.lua after building --keep-work Don't remove the build work directory + --follow Build all dependencies first (bottom-up), then the package. + Incompatible with --all. --force Overwrite existing package tarball --help Show this help @@ -101,6 +103,7 @@ local function parse_cli(args) force = false, index = false, all = nil, + follow = false, help = false, recipe = nil, jobs = nil, -- nil = auto-detect (nproc) @@ -142,6 +145,8 @@ local function parse_cli(args) opts.keep_work = true elseif a == "--force" then opts.force = true + elseif a == "--follow" then + opts.follow = true elseif a == "--jobs" then i = i + 1 if i > #args then @@ -217,6 +222,11 @@ if opts.help then os.exit(0) end +if opts.follow and opts.all then + io.stderr:write("error: --follow and --all are incompatible\n") + os.exit(1) +end + if opts.index then indexer.generate(path.join(opts.output, "packages")) elseif opts.all then diff --git a/toolchain/examples/custom-demo/build.sh b/toolchain/examples/custom-demo/build.sh new file mode 100644 index 0000000..4f0a02a --- /dev/null +++ b/toolchain/examples/custom-demo/build.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# build.sh — example custom build script for zeta-makepkg. +# +# Invoked from the extracted source directory. The tool sets: +# $DESTDIR → staging tree root (e.g. /tmp/zeta-makepkg/mytool-.../stage) +# +# This script must compile/assemble the software and install every output +# file under $DESTDIR with standard FHS paths (usr/bin/, usr/lib/, etc.). +# +# For a real project you would replace the example below with: +# - Go: go build -o "$DESTDIR/usr/bin/mytool" . +# - Rust: cargo build --release && install -D target/release/mytool "$DESTDIR/usr/bin/mytool" +# - Zig: zig build -Doptimize=ReleaseSafe --prefix /usr -Ddestdir="$DESTDIR" +# - Shell: install -D mytool.sh "$DESTDIR/usr/bin/mytool" +# - Python: install -D mytool.py "$DESTDIR/usr/bin/mytool" +# - Anything you can script. + +set -eu + +echo "==> Building mytool (custom)" + +# Standalone Go binary: init module, build, install +go mod init mytool +go build -o mytool . + +# Install into the staging tree +install -Dm755 mytool "$DESTDIR/usr/bin/mytool" + +echo "==> Install complete" diff --git a/toolchain/examples/custom-demo/custom.recipe b/toolchain/examples/custom-demo/custom.recipe new file mode 100644 index 0000000..6608322 --- /dev/null +++ b/toolchain/examples/custom-demo/custom.recipe @@ -0,0 +1,23 @@ +-- custom.recipe — demonstrates build_system = "custom" with a build.sh script. +-- +-- The custom build system runs `build_script` from inside the extracted +-- source directory. The script receives $DESTDIR pointing at the staging +-- tree where installed files must be placed. +-- +-- To try this: +-- 1. Unpack the source tarball: tar -xzf mytool-1.0.tar.gz +-- 2. Run the script manually: cd mytool-1.0 && DESTDIR=/tmp/stage sh build.sh +-- 3. Inspect the staged output: find /tmp/stage +-- 4. Build with makepkg: zeta-makepkg custom.recipe + +return { + name = "mytool", + version = "1.0", + summary = "Example custom-build package (static Go binary)", + url = "mytool-1.0.tar.gz", + sha256 = nil, + deps = {}, + build_system = "custom", + build_script = "build.sh", + test = "test -x ${DESTDIR}/usr/bin/mytool", +} diff --git a/toolchain/lib/build.lua b/toolchain/lib/build.lua index e402f40..df0da16 100644 --- a/toolchain/lib/build.lua +++ b/toolchain/lib/build.lua @@ -12,11 +12,86 @@ local builder = require("builder") local packager = require("packager") local indexer = require("indexer") +--------------------------------------------------------------------------- +-- Dependency resolution (--follow) +--------------------------------------------------------------------------- + +-- Given a recipe directory and a dep spec (e.g. "ncurses" or "libfoo>=2.0"), +-- return the path to .recipe if it exists, or nil. +local function find_dep_recipe(recipe_dir, dep_spec) + local name = dep_spec:match("^([%w%._+%-]+)") + if not name then return nil end + local p = path.join(recipe_dir, name .. ".recipe") + if path.exists(p) then return p end + return nil +end + +-- Recursively collect all dependency recipe paths, bottom-up (deps first). +-- Returns an ordered list (no duplicates) and an in-progress set for cycle +-- detection. Recipes for deps that don't have a .recipe file are silently +-- skipped (assumed already installed). +local function collect_deps(recipe_path, seen, order, in_progress) + if seen[recipe_path] then return end + if in_progress[recipe_path] then + error(("dependency cycle involving %s"):format(path.basename(recipe_path)), 0) + end + + in_progress[recipe_path] = true + + local r, err = recipe.load(recipe_path) + if not r then + in_progress[recipe_path] = nil + log.warn(("skipping %s: %s"):format(path.basename(recipe_path), tostring(err))) + return + end + + local dir = path.dirname(recipe_path) + for _, dep in ipairs(r.deps) do + local dep_recipe = find_dep_recipe(dir, dep) + if dep_recipe then + collect_deps(dep_recipe, seen, order, in_progress) + end + end + + in_progress[recipe_path] = nil + seen[recipe_path] = true + order[#order + 1] = recipe_path +end + +-- Return an ordered list of recipe paths to build: all dependencies +-- (bottom-up), then the target recipe itself. +local function resolve_deps(recipe_path) + local seen = {} + local order = {} + local in_progress = {} + collect_deps(recipe_path, seen, order, in_progress) + return order +end + --------------------------------------------------------------------------- -- Single recipe pipeline --------------------------------------------------------------------------- function build.single(recipe_path, opts) + -- If --follow, build dependencies first (bottom-up order, already sorted). + if opts.follow then + log.step("resolving dependencies") + local ordered = resolve_deps(recipe_path) + local dep_count = #ordered - 1 -- last is the target itself + if dep_count > 0 then + log.info(("found %d dependenc%s"):format(dep_count, dep_count == 1 and "y" or "ies")) + for i = 1, dep_count do + io.write("\n" .. string.rep("─", 36) .. "\n") + log.step(("building dependency %s (%d/%d)"):format( + path.basename(ordered[i]), i, dep_count)) + build.single(ordered[i], opts) + end + io.write("\n" .. string.rep("─", 36) .. "\n") + else + log.info("no unresolved dependencies") + end + end + local r, err = recipe.load(recipe_path) if not r then error(("recipe error: %s"):format(tostring(err)), 0) end @@ -37,7 +112,8 @@ function build.single(recipe_path, opts) log.info(("work directory: %s"):format(work_dir)) local cache_dir = path.join(os.getenv("HOME") or "/tmp", ".cache", "zeta-makepkg", "sources") - local cache_file = builder.fetch_source(r, cache_dir) + local recipe_dir = path.dirname(recipe_path) + local cache_file = builder.fetch_source(r, cache_dir, recipe_dir) local source_dir = builder.extract_source(r, cache_file, work_dir) builder.run_build(r, source_dir, stage_dir, { jobs = opts.jobs }) diff --git a/toolchain/lib/builder.lua b/toolchain/lib/builder.lua index 9dadb02..55c5d4d 100644 --- a/toolchain/lib/builder.lua +++ b/toolchain/lib/builder.lua @@ -104,7 +104,7 @@ end -- 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) +function builder.fetch_source(recipe, cache_dir, local_dir) local url = recipe.url -- Git repos are cloned, not cached as files. @@ -141,7 +141,16 @@ function builder.fetch_source(recipe, cache_dir) error(("failed to copy %s"):format(url), 0) end else - error(("unrecognised url scheme: %s"):format(url), 0) + -- Bare filename: resolve relative to the recipe's directory. + if local_dir then + local src = path.join(local_dir, url) + 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 + else + error(("unrecognised url scheme: %s"):format(url), 0) + end end else log.info("using cached source: " .. path.basename(cache_file))