feat(build): invoke zeta-makepkg with conditional --force
- Add tofu.build module with runMakepkg() public API - BuildException for missing binary, non-zero exit, missing output - Real-time stdout/stderr streaming with last-20-lines stderr capture - Conditional --force flag based on force parameter - zetaToolchainPath resolution from Config - 7 unittests using fake zeta-makepkg shell scripts - pipeProcess + Redirect.stderr for tee approach
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
/// tofu build — invoke zeta-makepkg as a subprocess with conditional --force.
|
||||
///
|
||||
/// Streams stdout/stderr to terminal in real time. Captures the last 20 lines
|
||||
/// of stderr for error reporting (tee approach: piped stderr → forward + buffer).
|
||||
module tofu.build;
|
||||
|
||||
import tofu.config : Config;
|
||||
import std.process : pipeProcess, Redirect, ProcessException, Pid, wait;
|
||||
import std.file : exists, mkdirRecurse;
|
||||
import std.path : baseName;
|
||||
import std.string : indexOf, strip, splitLines, join, toStringz;
|
||||
import std.conv : to, octal;
|
||||
import std.stdio : stderr;
|
||||
import std.exception : basicExceptionCtors;
|
||||
import core.thread : Thread;
|
||||
|
||||
version (unittest) {
|
||||
import std.file : write, tempDir, remove, rmdirRecurse, readText;
|
||||
import std.path : buildPath;
|
||||
import std.string : format;
|
||||
import core.sys.posix.sys.stat : chmod;
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown on build failures: missing binary, non-zero exit, missing output.
|
||||
class BuildException : Exception {
|
||||
mixin basicExceptionCtors;
|
||||
|
||||
this(string message, string file = __FILE__, size_t line = __LINE__)
|
||||
@safe pure nothrow {
|
||||
super(message, file, line);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Minimal ring buffer for stderr capture (up to `cap` lines).
|
||||
/// Callers MUST ensure happens-before via Thread.join() before calling get().
|
||||
private final class StderrRing {
|
||||
string[] _lines;
|
||||
size_t cap;
|
||||
|
||||
this(size_t n) @safe nothrow {
|
||||
cap = n;
|
||||
}
|
||||
|
||||
void add(string line) @trusted nothrow {
|
||||
_lines ~= line;
|
||||
if (_lines.length > cap)
|
||||
_lines = _lines[1 .. $]; // drop oldest
|
||||
}
|
||||
|
||||
string[] get() @trusted nothrow {
|
||||
return _lines.dup;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run zeta-makepkg through pipeProcess, tee stderr, return exit code + last 20
|
||||
/// stderr lines in `last20`. stdout inherits the parent terminal.
|
||||
///
|
||||
/// The reader thread runs `stderrFile.byLine` and both buffers the line and
|
||||
/// writes it to the real `stderr`. `join()` guarantees the buffer is filled
|
||||
/// before this function returns.
|
||||
private @trusted
|
||||
int execMakepkg(const string[] cmd, out string[] last20) {
|
||||
auto pp = pipeProcess(cmd, Redirect.stderr);
|
||||
|
||||
auto ring = new StderrRing(20);
|
||||
|
||||
// Forward-and-capture thread — reads piped stderr line by line.
|
||||
auto reader = new Thread(delegate () {
|
||||
try {
|
||||
auto f = pp.stderr;
|
||||
foreach (line; f.byLine()) {
|
||||
// byLine returns char[] with terminator; idup for safe storage
|
||||
string s = line.idup;
|
||||
ring.add(s);
|
||||
// Write to parent stderr — tee
|
||||
stderr.write(line);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Pipe closed / broken — safe to ignore.
|
||||
}
|
||||
});
|
||||
reader.start();
|
||||
|
||||
auto code = wait(pp.pid);
|
||||
reader.join();
|
||||
|
||||
last20 = ring.get();
|
||||
return code;
|
||||
}
|
||||
|
||||
/// Derive package name from recipe filename: strip directory and .recipe suffix.
|
||||
/// "hello.recipe" → "hello", "/path/to/pkg.recipe" → "pkg".
|
||||
private @safe string pkgNameFromPath(string recipePath) {
|
||||
auto f = baseName(recipePath);
|
||||
auto pos = f.indexOf(".recipe");
|
||||
if (pos > 0)
|
||||
return f[0 .. pos];
|
||||
// No .recipe suffix — use whole filename (minus any other extension)
|
||||
auto dot = f.indexOf('.');
|
||||
if (dot > 0)
|
||||
return f[0 .. dot];
|
||||
return f;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Invoke zeta-makepkg to build a recipe into `outputDir`.
|
||||
///
|
||||
/// Parameters:
|
||||
/// recipePath = path to the .recipe file
|
||||
/// outputDir = directory where packages/<name>/package.lua is produced
|
||||
/// jobs = value for -j<N> flag (parallel build jobs)
|
||||
/// force = if true, pass --force (overwrite existing package)
|
||||
/// cfg = tofu config (used for zetaToolchainPath resolution)
|
||||
///
|
||||
/// Returns:
|
||||
/// Absolute path to the produced package.lua on exit code 0.
|
||||
///
|
||||
/// Throws:
|
||||
/// BuildException when the binary is missing, the build exits non-zero,
|
||||
/// or the expected package.lua is not found after a successful exit.
|
||||
string runMakepkg(string recipePath, string outputDir, int jobs, bool force,
|
||||
Config cfg) @safe {
|
||||
|
||||
// ── 1. Resolve zeta-makepkg binary ───────────────────────────────────────
|
||||
string zetaMakepkg;
|
||||
if (cfg.zetaToolchainPath.length > 0) {
|
||||
zetaMakepkg = cfg.zetaToolchainPath;
|
||||
if (!() @trusted { return exists(zetaMakepkg); }())
|
||||
throw new BuildException(
|
||||
"zeta-makepkg not found. Install zeta-toolchain or set "
|
||||
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
|
||||
} else {
|
||||
zetaMakepkg = "zeta-makepkg";
|
||||
}
|
||||
|
||||
// ── 2. Ensure output directory exists ────────────────────────────────────
|
||||
() @trusted { mkdirRecurse(outputDir); }();
|
||||
|
||||
// ── 3. Derive package name ───────────────────────────────────────────────
|
||||
string pkgName = pkgNameFromPath(recipePath);
|
||||
|
||||
// ── 4. Build command-line arguments ─────────────────────────────────────
|
||||
string[] cmd = [
|
||||
zetaMakepkg,
|
||||
recipePath,
|
||||
"--output", outputDir,
|
||||
"-j" ~ to!string(jobs),
|
||||
"--no-index",
|
||||
"--repo", "https://files.spectoria.dev/zuur/binary",
|
||||
];
|
||||
if (force)
|
||||
cmd ~= "--force";
|
||||
|
||||
// ── 5. Execute ──────────────────────────────────────────────────────────
|
||||
string[] last20Lines;
|
||||
int exitCode;
|
||||
try {
|
||||
() @trusted {
|
||||
exitCode = execMakepkg(cmd, last20Lines);
|
||||
}();
|
||||
} catch (ProcessException e) {
|
||||
// Binary not found on PATH (or exec failed)
|
||||
if (cfg.zetaToolchainPath.length == 0
|
||||
&& e.msg.indexOf("not found") >= 0) {
|
||||
throw new BuildException(
|
||||
"zeta-makepkg not found. Install zeta-toolchain or set "
|
||||
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
|
||||
}
|
||||
throw new BuildException(
|
||||
"failed to execute zeta-makepkg: " ~ e.msg);
|
||||
}
|
||||
|
||||
// ── 6. Check result ─────────────────────────────────────────────────────
|
||||
if (exitCode != 0) {
|
||||
string errDetail;
|
||||
if (last20Lines.length > 0)
|
||||
errDetail = last20Lines.join("\n");
|
||||
else
|
||||
errDetail = "(no stderr output)";
|
||||
throw new BuildException(
|
||||
"build failed for " ~ pkgName ~ ":\n" ~ errDetail);
|
||||
}
|
||||
|
||||
// ── 7. Verify package.lua exists ─────────────────────────────────────────
|
||||
auto pkgPath = outputDir ~ "/packages/" ~ pkgName ~ "/package.lua";
|
||||
if (!() @trusted { return exists(pkgPath); }())
|
||||
throw new BuildException(
|
||||
"zeta-makepkg reported success but package.lua not found at "
|
||||
~ pkgPath);
|
||||
|
||||
return pkgPath;
|
||||
}
|
||||
|
||||
// ─── Unittests ───────────────────────────────────────────────────────────────
|
||||
|
||||
version (unittest) {
|
||||
|
||||
import std.process : thisProcessID;
|
||||
|
||||
/// Write text to a file (trusted).
|
||||
private @trusted void sWrite(string path, string content) {
|
||||
write(path, content);
|
||||
}
|
||||
|
||||
/// Remove a directory tree, ignoring errors.
|
||||
private @trusted void sRmdirRecurse(string path) {
|
||||
try { rmdirRecurse(path); } catch (Exception) {}
|
||||
}
|
||||
|
||||
/// Build a unique temp directory path for a test and create it.
|
||||
private @trusted string testTempDir(string suffix) {
|
||||
auto path = buildPath(tempDir, "tofu-test-build-" ~ suffix
|
||||
~ "-" ~ thisProcessID.to!string);
|
||||
mkdirRecurse(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Create a fake zeta-makepkg shell script that:
|
||||
/// - parses --output <dir> from its args
|
||||
/// - derives the package name from the first argument (recipe path)
|
||||
/// - creates packages/<name>/package.lua
|
||||
/// - echoes to stderr for output testing
|
||||
/// - writes all args to ARGS_FILE if set (for --force assertion tests)
|
||||
/// - exits with code from EXIT_CODE if set (default 0)
|
||||
private string makeFakeMakepkg(string dir, string name,
|
||||
string exitCode = "0", string argsFile = "") @trusted {
|
||||
auto path = buildPath(dir, name);
|
||||
string script = "#!/bin/bash\nset -e\n";
|
||||
script ~= `RECIPE_PATH="$1"
|
||||
RECIPE_FILE=$(basename "$RECIPE_PATH")
|
||||
PKG_NAME="${RECIPE_FILE%.recipe}"
|
||||
|
||||
`;
|
||||
if (argsFile.length > 0) {
|
||||
// Args are saved in the unconditional block below (ORIGINAL_ARGS).
|
||||
}
|
||||
script ~= `
|
||||
# Parse --output
|
||||
OUTPUT_DIR="."
|
||||
ORIGINAL_ARGS="$@"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--output) OUTPUT_DIR="$2"; shift 2;;
|
||||
*) shift;;
|
||||
esac
|
||||
done
|
||||
|
||||
`;
|
||||
if (argsFile.length > 0) {
|
||||
script ~= "echo \"$ORIGINAL_ARGS\" > '" ~ argsFile ~ "'\n";
|
||||
}
|
||||
script ~= `
|
||||
# Echo some stderr output (simulate build log)
|
||||
echo "zeta-makepkg: building ${PKG_NAME}..." >&2
|
||||
echo "zeta-makepkg: configure phase" >&2
|
||||
echo "zeta-makepkg: build phase" >&2
|
||||
echo "zeta-makepkg: install phase" >&2
|
||||
|
||||
# Create output
|
||||
mkdir -p "${OUTPUT_DIR}/packages/${PKG_NAME}"
|
||||
echo "return { version = '1.0.0' }" > "${OUTPUT_DIR}/packages/${PKG_NAME}/package.lua"
|
||||
|
||||
exit ` ~ exitCode ~ "\n";
|
||||
|
||||
write(path, script);
|
||||
chmod(toStringz(path), octal!755);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── Test (1): valid recipe → returns package.lua path, exit 0 ──────────
|
||||
@safe unittest {
|
||||
auto tmp = testTempDir("valid");
|
||||
scope (exit) sRmdirRecurse(tmp);
|
||||
|
||||
sWrite(buildPath(tmp, "hello.recipe"),
|
||||
"return { name = 'hello', version = '1.0.0' }");
|
||||
|
||||
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
|
||||
auto outDir = buildPath(tmp, "output");
|
||||
|
||||
Config cfg;
|
||||
cfg.zetaToolchainPath = fakeBin;
|
||||
cfg.zuurUrl = "https://files.spectoria.dev/zuur";
|
||||
|
||||
auto result = runMakepkg(
|
||||
buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
|
||||
|
||||
assert(result == buildPath(outDir, "packages", "hello", "package.lua"),
|
||||
"Expected packages/hello/package.lua, got: " ~ result);
|
||||
}
|
||||
|
||||
// ── Test (2): fake script exits 1 with stderr → BuildException ─────────
|
||||
@safe unittest {
|
||||
auto tmp = testTempDir("fail");
|
||||
scope (exit) sRmdirRecurse(tmp);
|
||||
|
||||
sWrite(buildPath(tmp, "hello.recipe"),
|
||||
"return { name = 'hello', version = '1.0.0' }");
|
||||
|
||||
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-fail", "1");
|
||||
auto outDir = buildPath(tmp, "output");
|
||||
|
||||
Config cfg;
|
||||
cfg.zetaToolchainPath = fakeBin;
|
||||
|
||||
try {
|
||||
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
|
||||
assert(false, "Expected BuildException");
|
||||
} catch (BuildException e) {
|
||||
assert(e.msg.indexOf("build failed for hello") >= 0,
|
||||
"Expected 'build failed for hello', got: " ~ e.msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (3): force=true → fake script receives --force ─────────────────
|
||||
@safe unittest {
|
||||
auto tmp = testTempDir("force-true");
|
||||
scope (exit) sRmdirRecurse(tmp);
|
||||
|
||||
sWrite(buildPath(tmp, "hello.recipe"),
|
||||
"return { name = 'hello', version = '1.0.0' }");
|
||||
|
||||
auto argsFile = buildPath(tmp, "args.txt");
|
||||
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-force", "0", argsFile);
|
||||
auto outDir = buildPath(tmp, "output");
|
||||
|
||||
Config cfg;
|
||||
cfg.zetaToolchainPath = fakeBin;
|
||||
|
||||
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, true, cfg);
|
||||
|
||||
auto rawArgs = () @trusted {
|
||||
try { return readText(argsFile); } catch (Exception) { return ""; }
|
||||
}();
|
||||
assert(rawArgs.indexOf("--force") >= 0,
|
||||
"Expected --force in args, got: " ~ rawArgs);
|
||||
}
|
||||
|
||||
// ── Test (4): force=false → fake script receives no --force ─────────────
|
||||
@safe unittest {
|
||||
auto tmp = testTempDir("force-false");
|
||||
scope (exit) sRmdirRecurse(tmp);
|
||||
|
||||
sWrite(buildPath(tmp, "hello.recipe"),
|
||||
"return { name = 'hello', version = '1.0.0' }");
|
||||
|
||||
auto argsFile = buildPath(tmp, "args.txt");
|
||||
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-noforce", "0", argsFile);
|
||||
auto outDir = buildPath(tmp, "output");
|
||||
|
||||
Config cfg;
|
||||
cfg.zetaToolchainPath = fakeBin;
|
||||
|
||||
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
|
||||
|
||||
auto rawArgs = () @trusted {
|
||||
try { return readText(argsFile); } catch (Exception) { return ""; }
|
||||
}();
|
||||
assert(rawArgs.indexOf("--force") < 0,
|
||||
"Expected no --force in args, got: " ~ rawArgs);
|
||||
}
|
||||
|
||||
// ── Test (5): zeta-makepkg not found (nonexistent path) ─────────────────
|
||||
@safe unittest {
|
||||
auto tmp = testTempDir("notfound");
|
||||
scope (exit) sRmdirRecurse(tmp);
|
||||
|
||||
sWrite(buildPath(tmp, "hello.recipe"),
|
||||
"return { name = 'hello', version = '1.0.0' }");
|
||||
|
||||
auto outDir = buildPath(tmp, "output");
|
||||
() @trusted { mkdirRecurse(outDir); }();
|
||||
|
||||
Config cfg;
|
||||
cfg.zetaToolchainPath = buildPath(tmp, "nonexistent-binary");
|
||||
|
||||
try {
|
||||
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
|
||||
assert(false, "Expected BuildException for missing binary");
|
||||
} catch (BuildException e) {
|
||||
assert(e.msg.indexOf("zeta-makepkg not found") >= 0,
|
||||
"Expected 'zeta-makepkg not found', got: " ~ e.msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (6): exits 0 but doesn't create package.lua → BuildException ───
|
||||
@safe unittest {
|
||||
auto tmp = testTempDir("missing-pkg");
|
||||
scope (exit) sRmdirRecurse(tmp);
|
||||
|
||||
sWrite(buildPath(tmp, "hello.recipe"),
|
||||
"return { name = 'hello', version = '1.0.0' }");
|
||||
|
||||
// Create a fake that exits 0 but creates no package.lua
|
||||
auto fakePath = buildPath(tmp, "fake-nopkg");
|
||||
string noPkgScript = "#!/bin/bash\necho 'done' >&2\nexit 0\n";
|
||||
() @trusted {
|
||||
write(fakePath, noPkgScript);
|
||||
chmod(toStringz(fakePath), octal!755);
|
||||
}();
|
||||
|
||||
auto outDir = buildPath(tmp, "output");
|
||||
|
||||
Config cfg;
|
||||
cfg.zetaToolchainPath = fakePath;
|
||||
|
||||
try {
|
||||
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
|
||||
assert(false, "Expected BuildException for missing package.lua");
|
||||
} catch (BuildException e) {
|
||||
assert(e.msg.indexOf("package.lua not found") >= 0,
|
||||
"Expected 'package.lua not found', got: " ~ e.msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test (7): pkgNameFromPath edge cases ────────────────────────────────
|
||||
@safe unittest {
|
||||
assert(pkgNameFromPath("hello.recipe") == "hello");
|
||||
assert(pkgNameFromPath("/path/to/pkg.recipe") == "pkg");
|
||||
assert(pkgNameFromPath("a.b.recipe") == "a.b"); // last .recipe
|
||||
assert(pkgNameFromPath("norecipe") == "norecipe"); // no dot at all
|
||||
assert(pkgNameFromPath("/tmp/.config/build.sh") == "build");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user