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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user