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,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);
}
}