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:
@@ -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 phase[31merror[0m 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 phase[31merror[0m build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-235652/nonexistent.recipe
|
||||||
|
[33mwarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
[33mwarn package 'foreignpkg' was not installed by tofu — removing via Zeta anyway[0m
|
||||||
|
[2m . checking binary firefox ...[0m
|
||||||
|
- binary firefox-2.1.0 satisfies firefox>=2.0
|
||||||
|
[2m . checking binary libfoo ...[0m
|
||||||
|
- binary libfoo-1.9 does not satisfy libfoo>=2.0
|
||||||
|
[2m . checking binary noexist ...[0m
|
||||||
|
[2m . checking binary brokenpkg ...[0m
|
||||||
|
[2m . checking binary badpkg ...[0m
|
||||||
|
[2m . checking binary testpkg ...[0m
|
||||||
|
- binary testpkg-2.1.0 satisfies testpkg
|
||||||
|
[36m==> ──── building depA (1/3) ────[0m
|
||||||
|
[32m ok depA[0m
|
||||||
|
[36m==> ──── building depB (2/3) ────[0m
|
||||||
|
[32m ok depB[0m
|
||||||
|
[36m==> ──── building target (3/3) ────[0m
|
||||||
|
[32m ok target[0m
|
||||||
|
[36m==> ──── building depA (1/3) ────[0m
|
||||||
|
- nothing to build
|
||||||
|
[36m==> ──── building pkg (1/1) ────[0m
|
||||||
|
- pkg already built, skipping
|
||||||
|
[36m==> ──── building pkg (1/1) ────[0m
|
||||||
|
[32m ok pkg[0m
|
||||||
|
[32m ok removed testpkg[0m
|
||||||
|
removed ok[32m ok removed foreignpkg[0m
|
||||||
|
error cannot remove libfoo: still required by libbar (use --force to override)[31merror[0m cannot remove libfoo (use --force to override)
|
||||||
|
[32m ok removed testpkg[0m
|
||||||
|
removed without prompt[32m ok removed testpkg[0m
|
||||||
|
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
|
||||||
|
[33mwarn removing stale lock (PID 99999999 not alive)[0m
|
||||||
|
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 -
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry 'badpool': invalid pool 'bad_pool_value'[0m
|
||||||
|
- aborted by user
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[2m . custom build system: fetching scripts/build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:48507//index.lua[0m
|
||||||
|
[32m ok index loaded: 3 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:37359//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:33211//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:60681//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:49755//index.lua[0m
|
||||||
|
[32m ok index loaded: 0 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:55251//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:53781//index.lua[0m
|
||||||
|
[32m ok index loaded: 2 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:48381//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:47133//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
|
||||||
|
[32m ok installed A with 2 dependencies[0m
|
||||||
|
- nothing to install
|
||||||
|
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstall[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-235652/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-235652/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
20 modules passed unittests
|
||||||
|
- already installed — skipping
|
||||||
|
[32m ok installed A with 0 dependencies[0m
|
||||||
|
- 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)
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + C (recipe)
|
||||||
|
- + A (recipe)
|
||||||
|
[32m ok build plan: 2 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- recipe libfoo not in cache, re-fetching
|
||||||
|
- + libfoo (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- nothing to build (all binary)
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + mypkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + libfoo (recipe)
|
||||||
|
- + mypkg (recipe)
|
||||||
|
[32m ok build plan: 2 packages[0m
|
||||||
|
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
|
||||||
@@ -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 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.
|
- `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`.
|
- 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
@@ -22,6 +22,7 @@ import tofu.cli; // Command, ParsedArgs, parseArgs, helpText, CliExcept
|
|||||||
import tofu.log; // logError, logInfo
|
import tofu.log; // logError, logInfo
|
||||||
import tofu.commands.install; // installCommand
|
import tofu.commands.install; // installCommand
|
||||||
import tofu.commands.search; // searchCommand
|
import tofu.commands.search; // searchCommand
|
||||||
|
import tofu.commands.info : infoCommand;
|
||||||
import tofu.commands.remove; // removeCommand
|
import tofu.commands.remove; // removeCommand
|
||||||
|
|
||||||
import std.stdio; // writeln, stderr
|
import std.stdio; // writeln, stderr
|
||||||
@@ -113,12 +114,10 @@ int main(string[] args)
|
|||||||
return 1;
|
return 1;
|
||||||
|
|
||||||
case Command.remove_:
|
case Command.remove_:
|
||||||
logError("command 'remove' not implemented yet");
|
return removeCommand(pa.arg, pa, cfg);
|
||||||
return 1;
|
|
||||||
|
|
||||||
case Command.info:
|
case Command.info:
|
||||||
logError("command 'info' not implemented yet");
|
return infoCommand(pa.arg, pa, cfg);
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Any uncaught exception during dispatch — map to exit code
|
// Any uncaught exception during dispatch — map to exit code
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}();
|
||||||
|
}
|
||||||
@@ -151,6 +151,105 @@ void runLocalProvide(string pkgName, Config cfg)
|
|||||||
"install failed for " ~ pkgName ~ ": " ~ lastLines);
|
"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
|
/// Install orchestrator: verify all recipe-built packages exist in the
|
||||||
/// built cache, then invoke `zeta -LocalProvide` on the **root** package.
|
/// built cache, then invoke `zeta -LocalProvide` on the **root** package.
|
||||||
/// ZETA's `deps.resolve` walks the full dependency tree internally —
|
/// ZETA's `deps.resolve` walks the full dependency tree internally —
|
||||||
|
|||||||
Reference in New Issue
Block a user