feat(ui): Worker-Menü "Update Claude CLI" führt claude update aus
Neuer Eintrag im Worker-Menü der Titelleiste, der die Claude CLI über das konfigurierte claude_bin (ExecutableResolver, inkl. Shim-Handling) mit `claude update` aktualisiert. Ausgabe/Fehler landen im Footer-Strip (FlashFooterSuccess/FlashFooterError), Menüpunkt ist während des Laufs deaktiviert. 5-Minuten-Timeout tötet den Prozessbaum.
This commit is contained in:
@@ -745,6 +745,7 @@
|
|||||||
"repositories": "Repositories",
|
"repositories": "Repositories",
|
||||||
"checkForUpdates": "Nach Updates suchen",
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
"restartWorker": "Worker neu starten",
|
"restartWorker": "Worker neu starten",
|
||||||
|
"updateClaudeCli": "Claude CLI aktualisieren",
|
||||||
"worktrees": "Worktrees…",
|
"worktrees": "Worktrees…",
|
||||||
"weeklyReport": "Wochenbericht…",
|
"weeklyReport": "Wochenbericht…",
|
||||||
"usageMonitor": "Usage Monitor…",
|
"usageMonitor": "Usage Monitor…",
|
||||||
|
|||||||
@@ -745,6 +745,7 @@
|
|||||||
"repositories": "Repositories",
|
"repositories": "Repositories",
|
||||||
"checkForUpdates": "Check for updates",
|
"checkForUpdates": "Check for updates",
|
||||||
"restartWorker": "Restart worker",
|
"restartWorker": "Restart worker",
|
||||||
|
"updateClaudeCli": "Update Claude CLI",
|
||||||
"worktrees": "Worktrees…",
|
"worktrees": "Worktrees…",
|
||||||
"weeklyReport": "Weekly Report…",
|
"weeklyReport": "Weekly Report…",
|
||||||
"usageMonitor": "Usage Monitor…",
|
"usageMonitor": "Usage Monitor…",
|
||||||
|
|||||||
@@ -680,6 +680,96 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
|
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _updatingClaudeCli;
|
||||||
|
|
||||||
|
private bool CanUpdateClaudeCli() => !_updatingClaudeCli;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))]
|
||||||
|
private async Task UpdateClaudeCliAsync()
|
||||||
|
{
|
||||||
|
_updatingClaudeCli = true;
|
||||||
|
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
|
||||||
|
if (dto is null)
|
||||||
|
{
|
||||||
|
FlashFooterError("Update failed: worker offline.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (success, message) = await RunClaudeUpdateAsync(dto);
|
||||||
|
if (success) FlashFooterSuccess(message);
|
||||||
|
else FlashFooterError(message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingClaudeCli = false;
|
||||||
|
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto)
|
||||||
|
{
|
||||||
|
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
|
||||||
|
if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
|
||||||
|
|
||||||
|
var psi = resolved.IsShim
|
||||||
|
? BuildShimUpdatePsi(resolved.Path)
|
||||||
|
: new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } };
|
||||||
|
psi.UseShellExecute = false;
|
||||||
|
psi.RedirectStandardOutput = true;
|
||||||
|
psi.RedirectStandardError = true;
|
||||||
|
psi.CreateNoWindow = true;
|
||||||
|
|
||||||
|
using var process = new System.Diagnostics.Process { StartInfo = psi };
|
||||||
|
var stdout = new System.Text.StringBuilder();
|
||||||
|
var stderr = new System.Text.StringBuilder();
|
||||||
|
process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
|
||||||
|
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
|
||||||
|
|
||||||
|
try { process.Start(); }
|
||||||
|
catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); }
|
||||||
|
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||||
|
var timedOut = false;
|
||||||
|
// On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the
|
||||||
|
// async output readers drain/complete naturally (mirrors VerifyCommandRunner).
|
||||||
|
await using var ctr = cts.Token.Register(() =>
|
||||||
|
{
|
||||||
|
timedOut = true;
|
||||||
|
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
return timedOut
|
||||||
|
? (false, "Update failed: timed out after 5 minutes.")
|
||||||
|
: SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath)
|
||||||
|
{
|
||||||
|
var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" });
|
||||||
|
return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure so a test can drive it with fake process output instead of spawning a real `claude`.
|
||||||
|
internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr)
|
||||||
|
{
|
||||||
|
if (exitCode == 0)
|
||||||
|
return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date.");
|
||||||
|
|
||||||
|
var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? "";
|
||||||
|
return (false, $"Update failed (exit {exitCode}): {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? LastNonEmptyLine(string text) =>
|
||||||
|
text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void DismissBanner()
|
private void DismissBanner()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,8 @@
|
|||||||
Command="{Binding RestartWorkerCommand}"/>
|
Command="{Binding RestartWorkerCommand}"/>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
|
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
|
||||||
Command="{Binding OpenUsageMonitorCommand}"/>
|
Command="{Binding OpenUsageMonitorCommand}"/>
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.updateClaudeCli}"
|
||||||
|
Command="{Binding UpdateClaudeCliCommand}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.repositories}"
|
<MenuItem Header="{loc:Tr shell.menu.repositories}"
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class IslandsShellViewModelUpdateClaudeCliTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Success_uses_last_non_empty_output_line()
|
||||||
|
{
|
||||||
|
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
|
||||||
|
0, "Checking for updates...\nSuccessfully updated to 2.1.4\n", "");
|
||||||
|
|
||||||
|
Assert.True(success);
|
||||||
|
Assert.Equal("Successfully updated to 2.1.4", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Success_with_no_output_falls_back_to_default_message()
|
||||||
|
{
|
||||||
|
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(0, "", "");
|
||||||
|
|
||||||
|
Assert.True(success);
|
||||||
|
Assert.Equal("Claude CLI is already up to date.", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failure_reports_exit_code_and_last_stderr_line()
|
||||||
|
{
|
||||||
|
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
|
||||||
|
1, "", "network error\nrequest failed\n");
|
||||||
|
|
||||||
|
Assert.False(success);
|
||||||
|
Assert.Equal("Update failed (exit 1): request failed", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failure_falls_back_to_stdout_when_stderr_is_empty()
|
||||||
|
{
|
||||||
|
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
|
||||||
|
2, "some diagnostic line\n", "");
|
||||||
|
|
||||||
|
Assert.False(success);
|
||||||
|
Assert.Equal("Update failed (exit 2): some diagnostic line", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user