Merge subtask
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>Without git, no task can run (worktrees) — blocking.</summary>
|
||||
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<CheckResult> 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker.</summary>
|
||||
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<CheckResult> 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<string> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClaudeDo.Installer.Checks.Interfaces;
|
||||
|
||||
public interface IPortOwnerResolver
|
||||
{
|
||||
Task<string?> FindOwningProcessNameAsync(int port, CancellationToken ct);
|
||||
}
|
||||
@@ -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<string?> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -636,5 +636,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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,5 +636,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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user