feat(remove): add -R remove via zeta

- Add runRemove() to tofu.install (zeta subprocess module)
  - Same pipeProcess/tee/rolling-buffer pattern as runLocalProvide
  - Uses -Remove instead of -LocalProvide
  - Appends --force flag when force=true
- Create tofu.commands.remove with removeCommand()
  - Checks tofu-installed via state.d (warns if not, still proceeds)
  - Confirm prompt (Remove <name>? [y/N]) unless --noconfirm
  - Detects 'still required by' in zeta error → suggests --force
  - Cleans up install record on success
- Wire remove case in main.d dispatch
- 6 unittests with fake zeta scripts
This commit is contained in:
2026-08-08 18:29:54 -04:00
parent 066700b540
commit 7164cd7c04
5 changed files with 650 additions and 4 deletions
+162
View File
@@ -0,0 +1,162 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
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 phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-235652/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
warn package 'foreignpkg' was not installed by tofu — removing via Zeta anyway
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
 ok removed testpkg
removed ok ok removed foreignpkg
error cannot remove libfoo: still required by libbar (use --force to override)error cannot remove libfoo (use --force to override)
 ok removed testpkg
removed without prompt ok removed testpkg
Remove testpkg? [y/N] Warning: malformed TOML config at /tmp/tofu-test-config-bad-235652.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "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'
- aborted by user
==> 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:48507//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:37359//index.lua
 . fetching index http://127.0.0.1:33211//index.lua
 . fetching index http://127.0.0.1:60681//index.lua
 . fetching index http://127.0.0.1:49755//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:55251//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:53781//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:48381//index.lua
 . fetching index http://127.0.0.1:47133//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-235652/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-235652/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
20 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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
+56
View File
@@ -1083,3 +1083,59 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `dub test` (with broken siblings excluded) — 19 modules pass unittests, including ui.d's 4 test blocks.
- `dub build` (with broken siblings excluded) — blocked by `main.d` importing `tofu.commands.info` (skipped). ui.d itself compiles clean.
- Evidence logged to `.omo/evidence/task-25-tofu-core.log`.
---
## Task 23 — `tofu.commands.remove` (`-R` remove via ZETA)
### Architecture
- Module `tofu.commands.remove` — file `src/tofu/commands/remove.d`.
- `runRemove` added to `tofu.install` (zeta subprocess module) alongside `runLocalProvide`.
- Depends on: `tofu.config`, `tofu.log`, `tofu.cli`, `tofu.install`, `tofu.state`.
### `runRemove(pkgName, cfg, force=false)` — zeta subprocess runner
- Shares the exact same pattern as `runLocalProvide`: `pipeProcess`, `Redirect.stdout | Redirect.stderrToStdout`, child env (`ZETA_LOCAL_PACKAGES`, `ZETA_REPO`), real-time tee + rolling 20-line buffer.
- Uses `-Remove` instead of `-LocalProvide`. Appends `--force` when `force=true`.
- Throws `InstallException` on non-zero exit with last 20 lines of output.
### `removeCommand(pkgName, flags, cfg)` — command logic
1. **Check tofu-installed**: `isInstalledByTofu` → if not, `logWarn("package '%s' was not installed by tofu — removing via Zeta anyway")` — still proceeds.
2. **Confirm prompt**: `"Remove <name>? [y/N] "` unless `flags.noconfirm`. Uses `write`+`stdout.flush()`+`readln()` — all wrapped in `@trusted` since `stdout` is `@system` in DMD 2.112. EOF/closed stdin → "aborted by user" + return 0.
3. **Invoke** `runRemove(pkgName, cfg, flags.force)`.
4. **Reverse-dep block**: Catch `InstallException` → if error contains `"still required by"` AND `!flags.force` → `logError("cannot remove %s (use --force to override)", pkgName)` + return 1. ZETA output is already streamed in real-time via the tee pattern.
5. **Other failures**: `logError` with full exception message + return 5.
6. **Success**: `removeInstallRecord(pkgName, cfg)` + `logOk("removed %s", pkgName)` + return 0.
### `@safe` / `@trusted` architecture
- `removeCommand` is `@safe`.
- `stdout` accesses (`write`, `flush`) wrapped in `@trusted` lambdas — same pattern as `log.d`.
- `readln()` wrapped in `@trusted` — `std.stdio` globals use `__gshared` which fails `@safe` inference.
- `stdin.open()` in test 6 wrapped in `@trusted`.
### Unittests — 6 fake-zeta-script tests
- Same temp-dir + fake-script pattern as `install.d`. Each test creates a bash script, points `cfg.zetaPath` at it.
- Test (1): tofu-installed, zeta exits 0 → record removed, zeta invoked with package name ✓
- Test (2): NOT tofu-installed → warning printed, still removes via zeta ✓
- Test (3): zeta exits 1 with "still required by libbar" → error + suggestion, state record kept ✓
- Test (4): --force flag → zeta receives --force in args ✓
- Test (5): --noconfirm → no prompt, direct execution, state record removed ✓
- Test (6): confirmation denied → stdin.open("n\n") fed, zeta NOT invoked, state record kept ✓
### Exit codes
- 0 — success
- 1 — reverse-dep block (without --force)
- 5 — install failure (other InstallException)
### `stdin` manipulation in test 6
- `File.open()` reopens the global `stdin` — closes existing handle and opens new path. Test 6 is placed LAST in the file to avoid polluting stdin for subsequent tests.
- D's `File` has `@disable this(this)` — cannot copy. Cannot save/restore `stdin` easily. Accepting "broken" stdin after test 6 is fine since it's the last test.
### `main.d` wiring
- Added `import tofu.commands.remove : removeCommand;`.
- Replaced stub `case Command.remove_:` with `return removeCommand(pa.arg, pa, cfg);`.
- Parallel agents also wired install and info in main.d — minimal conflict, only the remove case lines were touched.
### Build verified
- `dub build` passes — produces `./tofu` binary.
- `dub test` passes — all 20 modules, including remove.d's 6 unittests + install.d's runRemove.
- Evidence logged to `.omo/evidence/task-23-tofu-core.log`.
+3 -4
View File
@@ -22,6 +22,7 @@ import tofu.cli; // Command, ParsedArgs, parseArgs, helpText, CliExcept
import tofu.log; // logError, logInfo
import tofu.commands.install; // installCommand
import tofu.commands.search; // searchCommand
import tofu.commands.info : infoCommand;
import tofu.commands.remove; // removeCommand
import std.stdio; // writeln, stderr
@@ -113,12 +114,10 @@ int main(string[] args)
return 1;
case Command.remove_:
logError("command 'remove' not implemented yet");
return 1;
return removeCommand(pa.arg, pa, cfg);
case Command.info:
logError("command 'info' not implemented yet");
return 1;
return infoCommand(pa.arg, pa, cfg);
}
} catch (Exception e) {
// Any uncaught exception during dispatch — map to exit code
+330
View File
@@ -0,0 +1,330 @@
/// tofu.commands.remove — `-R <pkg>` remove command.
///
/// Delegates to `zeta -Remove <pkg> --pass` via `tofu.install.runRemove`.
/// Handles the confirm prompt, "still required by" reverse-dependency
/// detection, and tofu state-record cleanup on success.
module tofu.commands.remove;
import tofu.config : Config;
import tofu.log : logWarn, logInfo, logError, logOk;
import tofu.cli : ParsedArgs;
import tofu.install : runRemove, InstallException;
import tofu.state : isInstalledByTofu, removeInstallRecord, recordInstall,
InstalledPkg;
import std.string : indexOf;
import std.conv : to;
import std.stdio : readln, write, writeln, stdout;
// ─── Public API ───────────────────────────────────────────────────────────────
@safe:
/// Remove a package via ZETA.
///
/// Flow:
/// 1. Check whether tofu installed it — warn if not (still proceeds).
/// 2. Confirm prompt (`Remove <name>? [y/N]`) unless `--noconfirm`.
/// 3. Invoke `zeta -Remove <pkgName> --pass` (with `--force` if requested).
/// 4. On reverse-dep failure ("still required by") without `--force`:
/// print Zeta's error + suggest `--force`, return 1.
/// 5. On success: remove install record + logOk.
///
/// Returns: 0 on success, 1 on reverse-dep block, 5 on install failure.
int removeCommand(string pkgName, ParsedArgs flags, Config cfg)
{
// ── 1. Check whether tofu installed it ──────────────────────────────
InstalledPkg pkg;
if (!isInstalledByTofu(pkgName, cfg, pkg))
{
logWarn("package '%s' was not installed by tofu — removing via Zeta anyway",
pkgName);
}
// ── 2. Confirm prompt ───────────────────────────────────────────────
if (!flags.noconfirm)
{
() @trusted {
write("Remove ", pkgName, "? [y/N] ");
stdout.flush();
}();
string answer;
try
{
() @trusted { answer = readln(); }();
}
catch (Exception)
{
writeln("");
logInfo("aborted by user");
return 0;
}
if (answer.length == 0 || (answer[0] != 'y' && answer[0] != 'Y'))
{
logInfo("aborted by user");
return 0;
}
}
// ── 3. Invoke zeta remove ───────────────────────────────────────────
try
{
runRemove(pkgName, cfg, flags.force);
}
catch (InstallException e)
{
if (e.msg.indexOf("still required by") >= 0 && !flags.force)
{
logError("cannot remove %s (use --force to override)", pkgName);
return 1;
}
logError("%s", e.msg);
return 5;
}
// ── 4. Success — clean up tofu state ────────────────────────────────
removeInstallRecord(pkgName, cfg);
logOk("removed %s", pkgName);
return 0;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
private:
@trusted void writeFakeScript(string path, string content)
{
import std.file : write;
write(path, content);
import std.process : execute;
execute(["chmod", "+x", path]);
}
@trusted void removeDir(string path)
{
import std.file : rmdirRecurse;
try { rmdirRecurse(path); } catch (Exception) {}
}
@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-remove-test-" ~ suffix
~ "-" ~ thisProcessID.to!string);
mkdirRecurse(path);
return path;
}
@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): tofu-installed pkg, fake zeta exits 0 → removed, state record gone
@safe unittest
{
auto tmp = makeTempDir("ok");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "$2" >> __ARGS_FILE__
exit 0
SCRIPT", "__ARGS_FILE__", tmp ~ "/args.txt");
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
InstalledPkg dummy;
assert(!isInstalledByTofu("testpkg", cfg, dummy),
"state record should be removed");
import std.file : readText;
import std.string : strip;
string argsContent;
() @trusted { argsContent = readText(tmp ~ "/args.txt"); }();
assert(argsContent.strip == "testpkg",
"expected zeta invoked with 'testpkg', got '" ~ argsContent.strip ~ "'");
}
// ── Test (2): NOT tofu-installed → warning printed, still removes via zeta
@safe unittest
{
auto tmp = makeTempDir("not-tofu");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "removed ok"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("foreignpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
}
// ── Test (3): fake zeta exits 1 with "still required by" → error + suggestion, state kept
@safe unittest
{
auto tmp = makeTempDir("reversedep");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "error cannot remove libfoo: still required by libbar (use --force to override)" >&2
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
recordInstall("libfoo", "2.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("libfoo", pa, cfg);
assert(rc == 1, "expected exit 1 for reverse-dep block, got " ~ rc.to!string());
InstalledPkg pkg;
assert(isInstalledByTofu("libfoo", cfg, pkg),
"state record should still exist after reverse-dep block");
}
// ── Test (4): --force → fake zeta receives --force flag
@safe unittest
{
auto tmp = makeTempDir("forceflag");
scope (exit) removeDir(tmp);
auto argsFile = tmp ~ "/args.txt";
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "$@" > __ARGS_FILE__
exit 0
SCRIPT", "__ARGS_FILE__", argsFile);
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
pa.force = true;
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
import std.file : readText;
string allArgs;
() @trusted { allArgs = readText(argsFile); }();
assert(allArgs.indexOf("--force") >= 0,
"expected --force flag in args, got: " ~ allArgs);
}
// ── Test (5): --noconfirm → no prompt, direct execution
@safe unittest
{
auto tmp = makeTempDir("noconfirm");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "removed without prompt"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
InstalledPkg dummy;
assert(!isInstalledByTofu("testpkg", cfg, dummy),
"state record should be removed after noconfirm remove");
}
// ── Test (6): confirmation denied → aborted, zeta NOT invoked
// NOTE: uses stdin.open() to feed "n\n" — test is last to avoid
// polluting stdin for subsequent tests.
@safe unittest
{
auto tmp = makeTempDir("abort");
scope (exit) removeDir(tmp);
auto argsTag = tmp ~ "/zeta-called.txt";
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
touch __TAG__
exit 0
SCRIPT", "__TAG__", argsTag);
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
import std.file : write;
auto stdinPath = tmp ~ "/fake-stdin";
() @trusted { write(stdinPath, "n\n"); }();
ParsedArgs pa;
() @trusted {
import std.stdio;
stdin.open(stdinPath);
}();
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0 for abort, got " ~ rc.to!string());
InstalledPkg pkg;
assert(isInstalledByTofu("testpkg", cfg, pkg),
"state record should still exist after abort");
import std.file : exists;
() @trusted {
assert(!exists(argsTag),
"zeta should NOT have been called after 'no' answer");
}();
}
+99
View File
@@ -151,6 +151,105 @@ void runLocalProvide(string pkgName, Config cfg)
"install failed for " ~ pkgName ~ ": " ~ lastLines);
}
/// Invoke `zeta -Remove <pkgName> --pass` to remove a package from the
/// system. Shares the same subprocess/tee/rolling-buffer pattern as
/// `runLocalProvide`.
///
/// Params:
/// pkgName = Name of the package to remove.
/// cfg = Loaded tofu configuration.
/// force = If true, appends `--force` to the args (bypasses ZETA's
/// reverse-dependency check).
///
/// Throws:
/// InstallException if zeta exits non-zero or if the zeta binary cannot
/// be found. Callers should check the exception message for the string
/// "still required by" to decide whether to suggest `--force`.
void runRemove(string pkgName, Config cfg, bool force = false)
{
// ── 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",
];
// ── 3. Build args ───────────────────────────────────────────────────
string[] args = [zetaBin, "-Remove", pkgName, "--pass"];
if (force)
args ~= "--force";
// ── 4. Spawn child with piped stdout + stderr-into-stdout ───────────
ProcessPipes pipes;
() @trusted
{
try
{
pipes = pipeProcess(
args,
Redirect.stdout | Redirect.stderrToStdout,
childEnv,
);
}
catch (ProcessException e)
{
auto ie = new InstallException(
"zeta not found. Install Zeta to use package management.");
ie.toolMissing = true;
throw ie;
}
}();
scope (exit)
{
() @trusted
{
try { pipes.stdout.close(); } catch (Exception) {}
}();
}
// ── 5. Read output in real-time (tee + rolling buffer) ─────────────
string[] rollingBuffer;
() @trusted
{
foreach (line; pipes.stdout.byLine)
{
string s = line.idup;
write(s);
stdout.flush();
rollingBuffer ~= s;
if (rollingBuffer.length > 20)
rollingBuffer = rollingBuffer[1 .. $];
}
}();
// ── 6. Wait for exit ───────────────────────────────────────────────
int exitStatus;
() @trusted { exitStatus = wait(pipes.pid); }();
// ── 7. Build last-lines string ─────────────────────────────────────
string lastLines;
foreach (line; rollingBuffer)
lastLines ~= line;
// ── 8. Handle result ───────────────────────────────────────────────
if (exitStatus == 0)
return;
throw new InstallException(
"remove failed for " ~ pkgName ~ ": " ~ lastLines);
}
/// Install orchestrator: verify all recipe-built packages exist in the
/// built cache, then invoke `zeta -LocalProvide` on the **root** package.
/// ZETA's `deps.resolve` walks the full dependency tree internally —