Merge subtask

This commit is contained in:
mika kuns
2026-08-06 07:54:20 +02:00
17 changed files with 677 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
using System.Text.RegularExpressions;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without git, no task can run (worktrees) — blocking.</summary>
public sealed class GitCheck : IEnvironmentCheck
{
public const string CheckId = "git";
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
private readonly IProcessRunner _processRunner;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public GitCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
_processRunner = processRunner;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null)
{
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
"checks.git.hint", "https://git-scm.com/downloads");
}
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
var (exitCode, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0)
{
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
"checks.git.hint", "https://git-scm.com/downloads", output);
}
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
}
}
@@ -0,0 +1,60 @@
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
/// <summary>Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker.</summary>
public sealed class GitIdentityCheck : IEnvironmentCheck
{
public const string CheckId = "git-identity";
private readonly IProcessRunner _processRunner;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public GitIdentityCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
_processRunner = processRunner;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Warning;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked.");
}
var name = await ReadConfigAsync(resolved, "user.name", ct);
var email = await ReadConfigAsync(resolved, "user.email", ct);
var hasName = !string.IsNullOrWhiteSpace(name);
var hasEmail = !string.IsNullOrWhiteSpace(email);
if (hasName && hasEmail)
{
return CheckResult.Ok(Id, Severity, "checks.gitIdentity.title", $"{name} <{email}>");
}
var missing = (hasName, hasEmail) switch
{
(false, false) => "user.name, user.email",
(false, true) => "user.name",
_ => "user.email",
};
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
}
private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
{
var (fileName, arguments) = ProcessCommand.For(resolved, "config", "--get", key);
var (_, output) = await _processRunner.RunAsync(fileName, arguments, null, ct);
return output.Trim();
}
}
@@ -0,0 +1,6 @@
namespace ClaudeDo.Installer.Checks.Interfaces;
public interface IPortOwnerResolver
{
Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct);
}
@@ -0,0 +1,33 @@
using System.Diagnostics;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
public sealed class NetstatPortOwnerResolver : IPortOwnerResolver
{
private readonly IProcessRunner _processRunner;
public NetstatPortOwnerResolver(IProcessRunner processRunner) => _processRunner = processRunner;
public async Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct)
{
var (exitCode, output) = await _processRunner.RunAsync("netstat.exe", "-ano -p TCP", null, ct);
if (exitCode != 0) return null;
var suffix = $":{port}";
foreach (var line in output.Split('\n'))
{
var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5) continue;
if (!parts[0].Equals("TCP", StringComparison.OrdinalIgnoreCase)) continue;
if (!parts[1].EndsWith(suffix, StringComparison.Ordinal)) continue;
if (!int.TryParse(parts[^1], out var pid)) continue;
try { return Process.GetProcessById(pid).ProcessName; }
catch { return null; }
}
return null;
}
}
@@ -0,0 +1,71 @@
using System.Net;
using System.Net.Sockets;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Both ports are configurable in the installer settings, so a conflict is only a warning.</summary>
public sealed class PortCheck : IEnvironmentCheck
{
public const string CheckId = "ports";
private readonly IPortOwnerResolver _portOwnerResolver;
public PortCheck(IPortOwnerResolver portOwnerResolver) => _portOwnerResolver = portOwnerResolver;
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Warning;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var ports = new (string Label, int Port)[]
{
("SignalR", ctx.SignalRPort),
("External MCP", ctx.ExternalMcpPort),
};
var blocked = new List<string>();
var ownWorker = new List<string>();
foreach (var (label, port) in ports)
{
if (IsFree(port)) continue;
var owner = await _portOwnerResolver.FindOwningProcessNameAsync(port, ct);
if (owner is not null && owner.Contains("ClaudeDo.Worker", StringComparison.OrdinalIgnoreCase))
{
ownWorker.Add($"{label} ({port})");
continue;
}
blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'.");
}
if (blocked.Count > 0)
{
return CheckResult.Fail(Id, Severity, "checks.ports.title", string.Join(" ", blocked), "checks.ports.hint");
}
if (ownWorker.Count > 0)
{
return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}.");
}
return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available.");
}
private static bool IsFree(int port)
{
try
{
using var listener = new TcpListener(IPAddress.Loopback, port);
listener.Start();
return true;
}
catch (SocketException)
{
return false;
}
}
}
@@ -0,0 +1,20 @@
using ClaudeDo.Data.Environment;
namespace ClaudeDo.Installer.Checks;
/// <summary>Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe.</summary>
internal static class ProcessCommand
{
public static (string FileName, string Arguments) For(ResolvedExecutable resolved, params string[] args)
{
if (resolved.IsShim)
{
var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, args);
return (shim.FileName, shim.Arguments);
}
return (resolved.Path, string.Join(' ', args.Select(Quote)));
}
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
}
@@ -0,0 +1,62 @@
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without write access nothing can be installed — blocking.</summary>
public sealed class WriteAccessCheck : IEnvironmentCheck
{
public const string CheckId = "write-access";
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
{
var error = TryWrite(target);
if (error is not null)
{
return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
$"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
}
}
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable."));
}
private static string? TryWrite(string path)
{
var probeDir = FirstExistingParent(path);
var probeFile = Path.Combine(probeDir, $".claudedo-write-check-{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(probeFile, string.Empty);
return null;
}
catch (Exception ex)
{
return ex.Message;
}
finally
{
try { File.Delete(probeFile); } catch { /* best-effort cleanup */ }
}
}
private static string FirstExistingParent(string path)
{
var current = Path.GetFullPath(path);
while (!Directory.Exists(current))
{
var parent = Path.GetDirectoryName(current);
if (string.IsNullOrEmpty(parent) || parent == current) break;
current = parent;
}
return current;
}
}
@@ -0,0 +1,6 @@
namespace ClaudeDo.Installer.Core.Interfaces;
public interface IProcessRunner
{
Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct);
}
@@ -0,0 +1,9 @@
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed class ProcessRunnerAdapter : IProcessRunner
{
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) =>
ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct);
}
+18
View File
@@ -636,5 +636,23 @@
"lastError": "Letzter Fehler: {0}", "lastError": "Letzter Fehler: {0}",
"throttled": "Gedrosselt: {0}/{1} Slots ({2})" "throttled": "Gedrosselt: {0}/{1} Slots ({2})"
} }
},
"checks": {
"git": {
"title": "Git",
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
},
"gitIdentity": {
"title": "Git-Identität",
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
},
"ports": {
"title": "Ports",
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
},
"writeAccess": {
"title": "Schreibzugriff",
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
}
} }
} }
+18
View File
@@ -636,5 +636,23 @@
"lastError": "Last error: {0}", "lastError": "Last error: {0}",
"throttled": "Throttled: {0}/{1} slots ({2})" "throttled": "Throttled: {0}/{1} slots ({2})"
} }
},
"checks": {
"git": {
"title": "Git",
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
},
"gitIdentity": {
"title": "Git identity",
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
},
"ports": {
"title": "Ports",
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
},
"writeAccess": {
"title": "Write access",
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
}
} }
} }
@@ -0,0 +1,22 @@
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Tests.Checks;
internal sealed class FakeProcessRunner : IProcessRunner
{
private readonly Queue<(int ExitCode, string Output)> _responses;
public FakeProcessRunner(params (int ExitCode, string Output)[] responses)
{
_responses = new Queue<(int, string)>(responses);
}
public List<(string FileName, string Arguments)> Calls { get; } = new();
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct)
{
Calls.Add((fileName, arguments));
var response = _responses.Count > 0 ? _responses.Dequeue() : (0, "");
return Task.FromResult(response);
}
}
@@ -0,0 +1,66 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class GitCheckTests : IDisposable
{
private readonly string _dir;
public GitCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdgitcheck_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private string MakeGitExe()
{
var path = Path.Combine(_dir, "git.exe");
File.WriteAllText(path, "");
return path;
}
[Fact]
public async Task Git_present_with_parseable_version_reports_ok()
{
MakeGitExe();
var runner = new FakeProcessRunner((0, "git version 2.43.0.windows.1\n"));
var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.Contains("2.43.0", result.Message);
Assert.Contains(_dir, result.Message);
}
[Fact]
public async Task Git_not_found_on_path_reports_failed()
{
var runner = new FakeProcessRunner();
var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Error, result.Severity);
Assert.Empty(runner.Calls);
}
[Fact]
public async Task Git_nonzero_exit_reports_failed()
{
MakeGitExe();
var runner = new FakeProcessRunner((1, "some error"));
var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
}
}
@@ -0,0 +1,88 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class GitIdentityCheckTests : IDisposable
{
private readonly string _dir;
public GitIdentityCheckTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdgitid_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private void MakeGitExe() => File.WriteAllText(Path.Combine(_dir, "git.exe"), "");
[Fact]
public async Task Both_set_reports_ok()
{
MakeGitExe();
var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (0, "mika@example.com\n"));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task Only_name_set_reports_failed_naming_email()
{
MakeGitExe();
var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (1, ""));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Contains("user.email", result.Message);
Assert.DoesNotContain("user.name", result.Message);
}
[Fact]
public async Task Only_email_set_reports_failed_naming_name()
{
MakeGitExe();
var runner = new FakeProcessRunner((1, ""), (0, "mika@example.com\n"));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Contains("user.name", result.Message);
Assert.DoesNotContain("user.email", result.Message);
}
[Fact]
public async Task Neither_set_reports_failed_naming_both()
{
MakeGitExe();
var runner = new FakeProcessRunner((1, ""), (1, ""));
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Contains("user.name", result.Message);
Assert.Contains("user.email", result.Message);
}
[Fact]
public async Task Git_missing_reports_unknown_not_failed()
{
var runner = new FakeProcessRunner();
var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe");
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
Assert.Equal(CheckStatus.Unknown, result.Status);
Assert.Empty(runner.Calls);
}
}
@@ -0,0 +1,68 @@
using System.Net;
using System.Net.Sockets;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class PortCheckTests
{
private sealed class FakeOwnerResolver : IPortOwnerResolver
{
private readonly string? _owner;
public FakeOwnerResolver(string? owner) => _owner = owner;
public Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct) => Task.FromResult(_owner);
}
private static int GetFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
[Fact]
public async Task Both_ports_free_reports_ok()
{
var ctx = new InstallContext { SignalRPort = GetFreePort(), ExternalMcpPort = GetFreePort() };
var check = new PortCheck(new FakeOwnerResolver(null));
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task Port_in_use_by_other_process_reports_failed()
{
var port = GetFreePort();
using var occupying = new TcpListener(IPAddress.Loopback, port);
occupying.Start();
var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() };
var check = new PortCheck(new FakeOwnerResolver("SomeOtherApp"));
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Warning, result.Severity);
Assert.Contains("SomeOtherApp", result.Message);
}
[Fact]
public async Task Port_in_use_by_own_worker_reports_ok()
{
var port = GetFreePort();
using var occupying = new TcpListener(IPAddress.Loopback, port);
occupying.Start();
var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() };
var check = new PortCheck(new FakeOwnerResolver("ClaudeDo.Worker"));
var result = await check.RunAsync(ctx, CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
Assert.Contains("ClaudeDo Worker", result.Message);
}
}
@@ -0,0 +1,80 @@
using System.Security.AccessControl;
using System.Security.Principal;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests.Checks;
public sealed class WriteAccessCheckTests : IDisposable
{
private readonly List<string> _cleanupDirs = new();
private string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"cdwriteaccess_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
_cleanupDirs.Add(dir);
return dir;
}
public void Dispose()
{
foreach (var dir in _cleanupDirs)
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
[Fact]
public async Task Writable_directory_reports_ok()
{
var dir = TempDir();
var check = new WriteAccessCheck();
var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task NonExistent_path_under_writable_parent_reports_ok()
{
var parent = TempDir();
var target = Path.Combine(parent, "does", "not", "exist");
var check = new WriteAccessCheck();
var result = await check.RunAsync(new InstallContext { InstallDirectory = target }, CancellationToken.None);
Assert.Equal(CheckStatus.Ok, result.Status);
}
[Fact]
public async Task Unwritable_directory_reports_failed_with_no_leftover_file()
{
var dir = TempDir();
var dirInfo = new DirectoryInfo(dir);
var identity = WindowsIdentity.GetCurrent().User!;
var denyRule = new FileSystemAccessRule(identity, FileSystemRights.CreateFiles | FileSystemRights.WriteData, AccessControlType.Deny);
var acl = dirInfo.GetAccessControl();
acl.AddAccessRule(denyRule);
dirInfo.SetAccessControl(acl);
try
{
var check = new WriteAccessCheck();
var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None);
Assert.Equal(CheckStatus.Failed, result.Status);
Assert.Equal(CheckSeverity.Error, result.Severity);
Assert.Contains(dir, result.Message);
Assert.Empty(Directory.GetFiles(dir));
}
finally
{
acl.RemoveAccessRule(denyRule);
dirInfo.SetAccessControl(acl);
}
}
}
@@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" /> <PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.5.3" /> <PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup> </ItemGroup>