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:
2026-08-08 18:05:07 -04:00
parent 7928b4d5f2
commit da19ac98b1
3 changed files with 515 additions and 0 deletions
+71
View File
@@ -519,6 +519,77 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
---
## Task 16 — `tofu.install` (invoke ZETA -LocalProvide)
### Architecture
- Module `tofu.install` depends on: `tofu.config` (Config, builtPackagesDir, zuurUrl, zetaPath), `tofu.log` (logInfo).
- `runLocalProvide(pkgName, cfg)` invokes `zeta -LocalProvide <pkgName> --pass` with per-child environment.
- `InstallException : Exception` for install failures and missing zeta binary.
### Process spawning — `pipeProcess` not `spawnProcess`
- Used `std.process.pipeProcess` (not `spawnProcess`) because it returns `ProcessPipes` with piped stdout.
- Flags: `Redirect.stdout | Redirect.stderrToStdout` — pipes stdout and merges stderr into it.
- This avoids needing a separate thread for stderr capture (unlike build.d's approach).
- **Key difference from build.d**: `pipeProcess` + `Redirect.stderrToStdout` merges stderr into the stdout pipe — only one stream to read. build.d uses `Redirect.stderr` and a reader thread for separate stderr.
### Per-child environment via `pipeProcess` env parameter
- `pipeProcess(args, redirectFlags, env)` accepts `const string[string] env` — the child gets these env vars on top of the parent's environment.
- Simpler than the set-restore pattern on `std.process.environment` (no mutation of parent env, no race conditions).
- Set: `ZETA_LOCAL_PACKAGES` = `cfg.builtPackagesDir()`, `ZETA_REPO` = `cfg.zuurUrl ~ "/binary"`.
- `ZETA_ROOT` intentionally not set — config has no such field.
### Real-time output tee pattern
- Read from `pipes.stdout.byLine` (returns `char[]` with `\n` terminator by default).
- Each line: `.idup` to `string`, `write(s)` to parent stdout, `stdout.flush()` for real-time display.
- Rolling buffer: append to `string[]`, trim to last 20 lines (`rollingBuffer[1..$]`).
- On non-zero exit, concatenate rolling buffer into error message.
### `stderrToStdout` + ProcessPipes caveats
- When `Redirect.stderrToStdout` is used, `pipes.stderr` is **not** piped — accessing it throws `object.Error`.
- Similarly, `pipes.stdin` is not piped when not requested — accessing it throws.
- Must NOT attempt to close `pipes.stdin` or `pipes.stderr` when they weren't redirected.
- ZETA with `--pass` is non-interactive so the inherited stdin doesn't block.
### "already installed" handling
- ZETA `actions.localprovide` (lines 174–178): if `db.is_installed(name)`, prints "already installed -- use -ReProvide" and exits 0.
- Exit 0 + "already installed" in output → logInfo("already installed — skipping"), return normally.
- This is NOT an error — just a note.
### Unittests — fake zeta shell scripts
- Created per-test temp directories with `mkdirRecurse`, clean up with `scope(exit) rmdirRecurse`.
- Each test writes a bash script to `tmp/fake-zeta`, makes it executable (`chmod +x`), and points `cfg.zetaPath` at it.
- 5 test scenarios:
1. Exit 0 + env dump → returns, no throw (env file written by script, verified with readText)
2. Exit 1 + stderr → InstallException with last output lines
3. "already installed" + exit 0 → no throw, logInfo logged
4. Nonexistent binary path → InstallException "zeta not found"
5. Env correctness → stdout capture via File-swap, assert ZETA_LOCAL_PACKAGES and ZETA_REPO values
### D heredoc gotcha inside test scripts
- Cannot concatenate D strings inside `q"SCRIPT ... SCRIPT"` heredocs — the content is literal.
- Fix: use `__PLACEHOLDER__` and `std.string.replace` to inject dynamic paths into the script content before writing.
### `File.byLine` + terminator behavior
- `byLine` keeps `\n` terminator by default (`Yes.keepTerminator`).
- Forward with `write(s)` (no extra newline needed); the captured `\n` provides the line break.
- When building error message, concatenate directly (lines already end with `\n`).
### `Config` name conflict avoidance
- `std.process.Config` conflicts with `tofu.config.Config`.
- Selective imports: `import std.process : pipeProcess, ProcessPipes, Redirect, wait, ProcessException;` — no `Config` import needed.
- `pipeProcess`'s `config` parameter has a default value (`Config.none`), so explicit `Config` reference is unnecessary.
### Unused `if` block cleanup gotcha
- Empty `if` block (`if (x) { }`) triggers "statement has no effect" warnings → errors with `warningsAsErrors`.
- Remove completely rather than leaving empty.
### Build verified
- `dub test` passes — all 13 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve, build, install).
- `dub build` passes with `warningsAsErrors`.
- Evidence logged to `.omo/evidence/task-16-tofu-core.log`.
---
## Task 13 — `tofu.resolve.generateBuildPlan` (build plan from constrained tree)
### Architecture