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:
+3
-4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 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 —
|
||||
|
||||
Reference in New Issue
Block a user