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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user