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:
mika kuns
2026-08-05 19:25:02 +02:00
parent 7fa43b5737
commit 45bc324402
17 changed files with 677 additions and 0 deletions
@@ -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;
}
}