feat(installer): add Git, GitIdentity, Port, and WriteAccess preflight checks
Implements IEnvironmentCheck for the four checks derivable without a Claude CLI probe: - GitCheck (Error) - resolves git via ExecutableResolver (handles .cmd shims), parses `git --version`. - GitIdentityCheck (Warning) - user.name/user.email presence; Unknown (not Failed) if git itself is missing, so it doesn't duplicate GitCheck's failure. - PortCheck (Warning) - loopback bind probe for SignalRPort/ExternalMcpPort; resolves the owning process via a new NetstatPortOwnerResolver and treats a port held by the running ClaudeDo.Worker (update/repair case) as Ok. Both ports are configurable, hence a warning. - WriteAccessCheck (Error) - create+delete a probe file in InstallDirectory and ~/.todo-app (walking up to the first existing parent), not an ACL read (ACLs lie on virtualized paths). Process calls go through a new IProcessRunner wrapping the existing static ProcessRunner, so checks are fakeable in tests instead of spawning real processes. DotnetRuntimeCheck was intentionally not added: per docs/explore-notes/installer-preflight.md, App/Worker publish self-contained (no preinstalled runtime needed), and the Installer's own .NET 8 Desktop Runtime requirement is self-proving - a framework-dependent apphost can't reach managed code at all if that runtime is missing, so a check running from inside the process can never observe a failure. Brings in two prerequisite commits this task builds on that hadn't reached this branch yet: the IEnvironmentCheck/EnvironmentCheckService scaffolding and the installer-preflight.md research note.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user