From 45bc324402d10a54bd2fc730334d998c93814b63 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 19:25:02 +0200 Subject: [PATCH] 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. --- src/ClaudeDo.Installer/Checks/GitCheck.cs | 49 +++++++++++ .../Checks/GitIdentityCheck.cs | 60 +++++++++++++ .../Checks/Interfaces/IPortOwnerResolver.cs | 6 ++ .../Checks/NetstatPortOwnerResolver.cs | 33 +++++++ src/ClaudeDo.Installer/Checks/PortCheck.cs | 71 +++++++++++++++ .../Checks/ProcessCommand.cs | 20 +++++ .../Checks/WriteAccessCheck.cs | 62 +++++++++++++ .../Core/Interfaces/IProcessRunner.cs | 6 ++ .../Core/ProcessRunnerAdapter.cs | 9 ++ src/ClaudeDo.Localization/locales/de.json | 18 ++++ src/ClaudeDo.Localization/locales/en.json | 18 ++++ .../Checks/FakeProcessRunner.cs | 22 +++++ .../Checks/GitCheckTests.cs | 66 ++++++++++++++ .../Checks/GitIdentityCheckTests.cs | 88 +++++++++++++++++++ .../Checks/PortCheckTests.cs | 68 ++++++++++++++ .../Checks/WriteAccessCheckTests.cs | 80 +++++++++++++++++ .../ClaudeDo.Installer.Tests.csproj | 1 + 17 files changed, 677 insertions(+) create mode 100644 src/ClaudeDo.Installer/Checks/GitCheck.cs create mode 100644 src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs create mode 100644 src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs create mode 100644 src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs create mode 100644 src/ClaudeDo.Installer/Checks/PortCheck.cs create mode 100644 src/ClaudeDo.Installer/Checks/ProcessCommand.cs create mode 100644 src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs create mode 100644 src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs create mode 100644 src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs create mode 100644 tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs create mode 100644 tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs create mode 100644 tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs create mode 100644 tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs create mode 100644 tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs diff --git a/src/ClaudeDo.Installer/Checks/GitCheck.cs b/src/ClaudeDo.Installer/Checks/GitCheck.cs new file mode 100644 index 00000000..71aa3994 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/GitCheck.cs @@ -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; + +/// Without git, no task can run (worktrees) — blocking. +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 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}"); + } +} diff --git a/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs b/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs new file mode 100644 index 00000000..8a4a5a07 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs @@ -0,0 +1,60 @@ +using ClaudeDo.Data.Environment; +using ClaudeDo.Installer.Core; +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Checks; + +/// Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker. +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 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 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(); + } +} diff --git a/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs b/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs new file mode 100644 index 00000000..39e2a4e6 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Installer.Checks.Interfaces; + +public interface IPortOwnerResolver +{ + Task FindOwningProcessNameAsync(int port, CancellationToken ct); +} diff --git a/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs b/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs new file mode 100644 index 00000000..0a4c3e26 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs @@ -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 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; + } +} diff --git a/src/ClaudeDo.Installer/Checks/PortCheck.cs b/src/ClaudeDo.Installer/Checks/PortCheck.cs new file mode 100644 index 00000000..07f1ade1 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/PortCheck.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Sockets; +using ClaudeDo.Installer.Checks.Interfaces; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Both ports are configurable in the installer settings, so a conflict is only a warning. +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 RunAsync(InstallContext ctx, CancellationToken ct) + { + var ports = new (string Label, int Port)[] + { + ("SignalR", ctx.SignalRPort), + ("External MCP", ctx.ExternalMcpPort), + }; + + var blocked = new List(); + var ownWorker = new List(); + + 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; + } + } +} diff --git a/src/ClaudeDo.Installer/Checks/ProcessCommand.cs b/src/ClaudeDo.Installer/Checks/ProcessCommand.cs new file mode 100644 index 00000000..bef0ca4b --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ProcessCommand.cs @@ -0,0 +1,20 @@ +using ClaudeDo.Data.Environment; + +namespace ClaudeDo.Installer.Checks; + +/// Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe. +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; +} diff --git a/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs b/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs new file mode 100644 index 00000000..fe396c2e --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs @@ -0,0 +1,62 @@ +using System.IO; +using ClaudeDo.Data; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Without write access nothing can be installed — blocking. +public sealed class WriteAccessCheck : IEnvironmentCheck +{ + public const string CheckId = "write-access"; + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Error; + + public Task 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; + } +} diff --git a/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs b/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs new file mode 100644 index 00000000..5dbc20d5 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs @@ -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); +} diff --git a/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs b/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs new file mode 100644 index 00000000..d1ec89e8 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs @@ -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); +} diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 2504b9f3..63f4b72e 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -628,5 +628,23 @@ "lastError": "Letzter Fehler: {0}", "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)." + } } } diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 3c9a44e1..9bc23d6f 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -628,5 +628,23 @@ "lastError": "Last error: {0}", "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)." + } } } diff --git a/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs b/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs new file mode 100644 index 00000000..9095f6ba --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs new file mode 100644 index 00000000..aae7bd12 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs new file mode 100644 index 00000000..84b431a3 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs new file mode 100644 index 00000000..2a256f0c --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs @@ -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 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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs new file mode 100644 index 00000000..e0734afa --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs @@ -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 _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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj b/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj index 9a9b4eb2..aeb3d207 100644 --- a/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj +++ b/tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj @@ -14,6 +14,7 @@ +