-- 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