diff --git a/.omo/evidence/task-16-tofu-core.log b/.omo/evidence/task-16-tofu-core.log new file mode 100644 index 0000000..b5df34e --- /dev/null +++ b/.omo/evidence/task-16-tofu-core.log @@ -0,0 +1,129 @@ + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Generating test runner configuration 'tofu-test-application' for 'application' (executable). + Warning Excluding main source file src/main.d from test. + Starting Performing "unittest" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date. + Finished To force a rebuild of up-to-date targets, run again with --force + Running tofu-test-application +zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonewarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +Warning: malformed TOML config at /tmp/tofu-test-config-bad-211516.toml: Invalid table key declaration (2:0) +Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1 +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +warn skipping index entry with empty name +warn skipping index entry with empty name +warn skipping index entry 'badpool': invalid pool 'bad_pool_value' + . checking binary firefox ... + - binary firefox-2.1.0 satisfies firefox>=2.0 + . checking binary libfoo ... + - binary libfoo-1.9 does not satisfy libfoo>=2.0 + . checking binary noexist ... + . checking binary brokenpkg ... + . checking binary badpkg ... + . checking binary testpkg ... + - binary testpkg-2.1.0 satisfies testpkg +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + . downloading build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + . custom build system: fetching scripts/build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . fetching index http://127.0.0.1:47181//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:53573//index.lua + . fetching index http://127.0.0.1:58917//index.lua + . fetching index http://127.0.0.1:50501//index.lua + . fetching index http://127.0.0.1:53705//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:58781//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:48757//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:53589//index.lua + . fetching index http://127.0.0.1:59755//index.lua + ok index loaded: 1 packages +installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests + - already installed — skipping + - binary libfoo-2.1 satisfies libfoo>=2.0 + - libfoo: binary 1.9 too old, building from recipe + - libfoo: binary 1.9 too old, building from recipe + - binary libbar-3.0 satisfies libbar (unconstrained) + - libbaz: no binary available, building from recipe + - binary libfoo-1.5 satisfies libfoo>=1.0 + - libfoo: binary 1.5 too old, building from recipe + - binary parentA-1.5 satisfies parentA (unconstrained) + - binary parentB-1.5 satisfies parentB (unconstrained) +==> generating build plan + - + C (recipe) + - + A (recipe) + ok build plan: 2 packages +==> generating build plan + - recipe libfoo not in cache, re-fetching + - + libfoo (recipe) + ok build plan: 1 packages +==> generating build plan +==> generating build plan + - nothing to build (all binary) +==> generating build plan + - + mypkg (recipe) + ok build plan: 1 packages +==> generating build plan + - + libfoo (recipe) + - + mypkg (recipe) + ok build plan: 2 packages + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Starting Performing "debug" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Up-to-date tofu ~main: target for configuration [application] is up to date. + Finished To force a rebuild of up-to-date targets, run again with --force diff --git a/src/tofu/install.d b/src/tofu/install.d new file mode 100644 index 0000000..b906349 --- /dev/null +++ b/src/tofu/install.d @@ -0,0 +1,350 @@ +/// tofu.install — invoke ZETA to install built packages into the system. +/// +/// Wraps `zeta -LocalProvide --pass` with: +/// - Environment: `ZETA_LOCAL_PACKAGES` (built packages dir) and +/// `ZETA_REPO` (binary pool URL), passed per-child via `pipeProcess`. +/// - ZETA_ROOT is intentionally NOT set (config has no such field). +/// - Real-time output streaming with a rolling 20-line buffer for +/// error reporting. +/// +/// The per-child env is set via `pipeProcess`'s `env` parameter — the +/// child process inherits the parent env plus these overrides. No +/// parent-process environment mutation is needed. +module tofu.install; + +import tofu.config; +import tofu.log; + +// Selective imports to avoid `Config` name conflict between +// `std.process.Config` and `tofu.config.Config`. +import std.process : pipeProcess, ProcessPipes, Redirect, wait, + ProcessException; +import std.stdio : write, writeln, stdout; +import std.string : indexOf; + +// ─── Exception ─────────────────────────────────────────────────────────────── + +/// Thrown when a package install fails. +class InstallException : Exception +{ + this(string message, string file = __FILE__, size_t line = __LINE__) + @safe pure nothrow + { + super(message, file, line); + } +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +@safe: + +/// Invoke `zeta -LocalProvide --pass` to install a previously-built +/// package from the local tree into the system. +/// +/// The child process is spawned with `pipeProcess` so output is captured in +/// real-time: each line is written immediately to the parent's stdout and +/// buffered (last 20 lines) for error reporting. +/// +/// Params: +/// pkgName = Name of the package to install (passed to `-LocalProvide`). +/// cfg = Loaded tofu configuration. +/// +/// Throws: +/// InstallException if zeta exits non-zero or if the zeta binary cannot +/// be found. +/// +/// "already installed" messages (ZETA `actions.localprovide` line 174–178) +/// produce exit code 0 — treated as a non-error with a logInfo note. +void runLocalProvide(string pkgName, Config cfg) +{ + // ── 1. Determine zeta binary ──────────────────────────────────────── + + string zetaBin = cfg.zetaPath.length > 0 ? cfg.zetaPath : "zeta"; + + // ── 2. Build per-child environment ────────────────────────────────── + + const string[string] childEnv = [ + "ZETA_LOCAL_PACKAGES": cfg.builtPackagesDir(), + "ZETA_REPO": cfg.zuurUrl ~ "/binary", + ]; + // ZETA_ROOT intentionally not set — config has no ZETA_ROOT field. + + // ── 3. Spawn child with piped stdout + stderr-into-stdout ─────────── + + ProcessPipes pipes; + () @trusted + { + try + { + pipes = pipeProcess( + [zetaBin, "-LocalProvide", pkgName, "--pass"], + Redirect.stdout | Redirect.stderrToStdout, + childEnv, + ); + } + catch (ProcessException e) + { + throw new InstallException( + "zeta not found. Install Zeta to use package management."); + } + }(); + + scope (exit) + { + () @trusted + { + try { pipes.stdout.close(); } catch (Exception) {} + }(); + } + + // ── 4. Read output in real-time (tee + rolling buffer) ───────────── + + string[] rollingBuffer; + + () @trusted + { + foreach (line; pipes.stdout.byLine) + { + string s = line.idup; + write(s); // line already ends with \n + stdout.flush(); + rollingBuffer ~= s; + if (rollingBuffer.length > 20) + rollingBuffer = rollingBuffer[1 .. $]; + } + }(); + + // ── 5. Wait for exit ─────────────────────────────────────────────── + + int exitStatus; + () @trusted { exitStatus = wait(pipes.pid); }(); + + // ── 6. Build last-lines string (for error / "already installed") ─── + + string lastLines; + foreach (line; rollingBuffer) + lastLines ~= line; // line already ends with \n + + // ── 7. Handle result ─────────────────────────────────────────────── + + if (exitStatus == 0) + { + // ZETA actions.localprovide: already-installed + exit 0 → NOT an error. + if (lastLines.indexOf("already installed") >= 0) + { + logInfo("already installed — skipping"); + } + return; + } + + // Non-zero exit → InstallException with last 20 lines of output. + throw new InstallException( + "install failed for " ~ pkgName ~ ": " ~ lastLines); +} + +// ─── Unittests ─────────────────────────────────────────────────────────────── + +private: + +/// Write a shell script to the given path and make it executable. +@trusted void writeFakeScript(string path, string content) +{ + import std.file : write, setAttributes; + write(path, content); + // chmod +x + import std.process : execute; + execute(["chmod", "+x", path]); +} + +/// Remove a file, ignoring errors. +@trusted void removeFile(string path) +{ + import std.file : remove; + try { remove(path); } catch (Exception) {} +} + +/// Create a unique temp directory path for a test. +@trusted string makeTempDir(string suffix) +{ + import std.path : buildPath; + import std.file : tempDir, mkdirRecurse; + import std.process : thisProcessID; + import std.conv : to; + auto path = buildPath(tempDir, "tofu-install-test-" ~ suffix + ~ "-" ~ thisProcessID.to!string); + mkdirRecurse(path); + return path; +} + +/// Remove a directory tree, ignoring errors. +@trusted void removeDir(string path) +{ + import std.file : rmdirRecurse; + try { rmdirRecurse(path); } catch (Exception) {} +} + +/// Build a `Config` that points `zetaPath` at a fake script and uses +/// a given temp dir as cache root (so `builtPackagesDir()` resolves). +@safe Config testConfig(string zetaScript, string cacheDir) +{ + import tofu.config : load; + const string[string] env = [ + "TOFU_CACHE_DIR": cacheDir, + "TOFU_ZETA_PATH": zetaScript, + ]; + return load(null, env); +} + +// ── Test (1): fake zeta exits 0 → returns, no throw ───────────────────── + +@safe unittest +{ + auto tmp = makeTempDir("exit0"); + scope (exit) removeDir(tmp); + + // Fake script that writes env vars to a file and exits cleanly. + auto scriptPath = tmp ~ "/fake-zeta"; + import std.string : replace; + string scriptContent = replace(q"SCRIPT +#!/bin/bash +echo "ZETA_LOCAL_PACKAGES=$ZETA_LOCAL_PACKAGES" >> __ENV_FILE__ +echo "ZETA_REPO=$ZETA_REPO" >> __ENV_FILE__ +echo "installing package..." >&2 +exit 0 +SCRIPT", "__ENV_FILE__", tmp ~ "/env.txt"); + writeFakeScript(scriptPath, scriptContent); + + auto cfg = testConfig(scriptPath, tmp); + runLocalProvide("testpkg", cfg); + + // Verify env vars were set correctly. + import std.file : readText; + string envContent; + () @trusted { envContent = readText(tmp ~ "/env.txt"); }(); + assert(envContent.indexOf("ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()) >= 0); + assert(envContent.indexOf("ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary") >= 0); +} + +// ── Test (2): fake zeta exits 1 → InstallException ────────────────────── + +@safe unittest +{ + auto tmp = makeTempDir("exit1"); + scope (exit) removeDir(tmp); + + auto scriptPath = tmp ~ "/fake-zeta"; + writeFakeScript(scriptPath, q"SCRIPT +#!/bin/bash +echo "error: build failed" +echo "reason: missing dependency" +exit 1 +SCRIPT"); + + auto cfg = testConfig(scriptPath, tmp); + try + { + runLocalProvide("badpkg", cfg); + assert(false, "expected InstallException"); + } + catch (InstallException e) + { + assert(e.msg.indexOf("install failed for badpkg") >= 0); + assert(e.msg.indexOf("error: build failed") >= 0); + assert(e.msg.indexOf("reason: missing dependency") >= 0); + } +} + +// ── Test (3): "already installed" exit 0 → no throw, logInfo note ────── + +@safe unittest +{ + auto tmp = makeTempDir("already"); + scope (exit) removeDir(tmp); + + auto scriptPath = tmp ~ "/fake-zeta"; + writeFakeScript(scriptPath, q"SCRIPT +#!/bin/bash +echo "testpkg-2.1 is already installed -- use -ReProvide to reinstall" +exit 0 +SCRIPT"); + + auto cfg = testConfig(scriptPath, tmp); + // Should not throw. + runLocalProvide("testpkg", cfg); +} + +// ── Test (4): zeta not found → InstallException ──────────────────────── + +@safe unittest +{ + auto tmp = makeTempDir("notfound"); + scope (exit) removeDir(tmp); + + // Point at a nonexistent binary path. + auto cfg = testConfig(tmp ~ "/nonexistent-zeta", tmp); + + try + { + runLocalProvide("testpkg", cfg); + assert(false, "expected InstallException"); + } + catch (InstallException e) + { + assert(e.msg.indexOf("zeta not found") >= 0); + } +} + +// ── Test (5): env correctness (ZETA_LOCAL_PACKAGES, ZETA_REPO) ───────── + +@safe unittest +{ + auto tmp = makeTempDir("envcheck"); + scope (exit) removeDir(tmp); + + auto scriptPath = tmp ~ "/fake-zeta"; + // Script that dumps env vars and exits 0. + writeFakeScript(scriptPath, q"SCRIPT +#!/bin/bash +echo "ZETA_LOCAL_PACKAGES=$ZETA_LOCAL_PACKAGES" +echo "ZETA_REPO=$ZETA_REPO" +exit 0 +SCRIPT"); + + auto cfg = testConfig(scriptPath, tmp); + + // Capture stdout during the call to verify env values in output. + import std.file : exists, remove, tempDir; + import std.stdio : File; + + auto capturePath = tempDir ~ "/tofu-install-capture-" ~ + () @trusted { + import std.process : thisProcessID; + import std.conv : to; + return thisProcessID.to!string; + }() ~ ".txt"; + scope (exit) { () @trusted { if (exists(capturePath)) remove(capturePath); }(); } + + // Capture stdout via file-swap (trusted — mirrors log.d's capture()). + () @trusted { + auto captureFile = File(capturePath, "w+"); + auto savedStdout = stdout; + stdout = captureFile; + scope (exit) stdout = savedStdout; + + runLocalProvide("testpkg", cfg); + + stdout.flush(); + }(); + + // Read captured output. + import std.file : readText; + string captured; + () @trusted { captured = readText(capturePath); }(); + + assert(captured.indexOf("ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()) >= 0, + "expected ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir() + ~ " in: " ~ captured); + assert(captured.indexOf("ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary") >= 0, + "expected ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary" + ~ " in: " ~ captured); +}