feat(installer): pull in preflight check implementations as prerequisite for SystemCheckPage
Git/GitIdentity/Port/WriteAccess and Claude CLI/Version/Auth/PermissionModeAuto checks plus the ExecutableResolver they depend on were built in two sibling task branches that hadn't landed on main yet. Vendored the finished files in from those branches (same content, verified building + tests green) so the SystemCheckPage task has something to consume.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
using SysEnvironment = System.Environment;
|
||||
|
||||
namespace ClaudeDo.Data.Environment;
|
||||
|
||||
public sealed record ResolvedExecutable(string Path, bool IsShim);
|
||||
|
||||
public sealed record ShimStartInfo(string FileName, string Arguments);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds
|
||||
/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss.
|
||||
/// </summary>
|
||||
public static class ExecutableResolver
|
||||
{
|
||||
private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD";
|
||||
|
||||
// Known npm/claude install locations to try when PATH search comes up empty.
|
||||
private static readonly string[] FallbackDirectoryTemplates =
|
||||
{
|
||||
"%APPDATA%\\npm",
|
||||
"%LOCALAPPDATA%\\Programs\\claude",
|
||||
"%USERPROFILE%\\.local\\bin",
|
||||
};
|
||||
|
||||
public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null)
|
||||
{
|
||||
var pathExts = ParsePathExt(pathExtOverride);
|
||||
|
||||
if (LooksLikePath(command))
|
||||
{
|
||||
return ResolveAsPath(command, pathExts);
|
||||
}
|
||||
|
||||
var directories = ParsePath(pathOverride);
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
var resolved = ResolveInDirectory(dir, command, pathExts);
|
||||
if (resolved is not null) return resolved;
|
||||
}
|
||||
|
||||
foreach (var template in FallbackDirectoryTemplates)
|
||||
{
|
||||
var dir = SysEnvironment.ExpandEnvironmentVariables(template);
|
||||
var resolved = ResolveInDirectory(dir, command, pathExts);
|
||||
if (resolved is not null) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Expanded fallback directories tried when PATH search comes up empty (for diagnostics).</summary>
|
||||
public static IReadOnlyList<string> FallbackDirectories() =>
|
||||
FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList();
|
||||
|
||||
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var parts = new List<string> { "/c", Quote(shimPath) };
|
||||
parts.AddRange(arguments.Select(Quote));
|
||||
return new ShimStartInfo("cmd.exe", string.Join(' ', parts));
|
||||
}
|
||||
|
||||
private static bool LooksLikePath(string command) =>
|
||||
command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar);
|
||||
|
||||
private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList<string> pathExts)
|
||||
{
|
||||
if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command)));
|
||||
|
||||
if (Path.HasExtension(command)) return null;
|
||||
|
||||
foreach (var ext in pathExts)
|
||||
{
|
||||
var candidate = command + ext;
|
||||
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList<string> pathExts)
|
||||
{
|
||||
if (!Directory.Exists(directory)) return null;
|
||||
|
||||
if (Path.HasExtension(command))
|
||||
{
|
||||
var candidate = Path.Combine(directory, command);
|
||||
return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null;
|
||||
}
|
||||
|
||||
foreach (var ext in pathExts)
|
||||
{
|
||||
var candidate = Path.Combine(directory, command + ext);
|
||||
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsShimExtension(string extension) =>
|
||||
!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)
|
||||
&& !extension.Equals(".com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IReadOnlyList<string> ParsePathExt(string? pathExtOverride)
|
||||
{
|
||||
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
|
||||
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParsePath(string? pathOverride)
|
||||
{
|
||||
var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? "";
|
||||
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
|
||||
}
|
||||
@@ -11,16 +11,16 @@ public static class Paths
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Path must not be empty.", nameof(path));
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(path);
|
||||
var expanded = System.Environment.ExpandEnvironmentVariables(path);
|
||||
|
||||
if (expanded.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
|
||||
expanded = home + expanded[1..];
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(expanded))
|
||||
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
|
||||
expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory);
|
||||
|
||||
return Path.GetFullPath(expanded);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public enum CheckSeverity { Warning, Error }
|
||||
|
||||
public enum CheckStatus { Ok, Failed, Unknown }
|
||||
|
||||
public sealed record CheckResult(
|
||||
string Id,
|
||||
CheckSeverity Severity,
|
||||
CheckStatus Status,
|
||||
string TitleKey,
|
||||
string Message,
|
||||
string? HintKey,
|
||||
string? HelpUrl,
|
||||
string? Detail)
|
||||
{
|
||||
public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
|
||||
new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail);
|
||||
|
||||
public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) =>
|
||||
new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail);
|
||||
|
||||
public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
|
||||
new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks login via `claude auth status --json` — never sends a prompt (costs tokens, can hang
|
||||
/// on a usage-limited account). See docs/explore-notes/installer-preflight.md §4.
|
||||
/// </summary>
|
||||
public sealed class ClaudeAuthCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-auth";
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public ClaudeAuthCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"The claude CLI was not found — login could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
|
||||
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
var loggedIn = TryParseLoggedIn(output);
|
||||
if (loggedIn is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
return loggedIn.Value
|
||||
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.")
|
||||
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint");
|
||||
}
|
||||
|
||||
private static bool? TryParseLoggedIn(string output)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(output);
|
||||
if (doc.RootElement.TryGetProperty("loggedIn", out var prop) &&
|
||||
prop.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return prop.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// fall through to null — treated as Unknown
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Without the claude CLI, no task can ever run — blocking.</summary>
|
||||
public sealed class ClaudeCliCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-cli";
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public ClaudeCliCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
var searched = string.Join(", ", SearchedLocations());
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
|
||||
"checks.claudeCli.hint");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
|
||||
"checks.claudeCli.hint", detail: lookup.Output);
|
||||
}
|
||||
|
||||
var version = lookup.ParsedVersion?.ToString() ?? lookup.Output.Trim();
|
||||
var detail = lookup.Resolved.IsShim
|
||||
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
|
||||
: null;
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail);
|
||||
}
|
||||
|
||||
private IEnumerable<string> SearchedLocations()
|
||||
{
|
||||
var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return pathEntries.Concat(ExecutableResolver.FallbackDirectories());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed record ClaudeCliLookupResult(
|
||||
ResolvedExecutable? Resolved,
|
||||
int ExitCode,
|
||||
string Output,
|
||||
Version? ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the claude CLI and runs `--version` at most once per install run, shared across
|
||||
/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck.
|
||||
/// </summary>
|
||||
public sealed class ClaudeCliLookup
|
||||
{
|
||||
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
|
||||
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private ClaudeCliLookupResult? _cached;
|
||||
|
||||
public ClaudeCliLookup(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
|
||||
{
|
||||
ProcessRunner = processRunner;
|
||||
PathOverride = pathOverride;
|
||||
PathExtOverride = pathExtOverride;
|
||||
}
|
||||
|
||||
public IProcessRunner ProcessRunner { get; }
|
||||
public string? PathOverride { get; }
|
||||
public string? PathExtOverride { get; }
|
||||
|
||||
public async Task<ClaudeCliLookupResult> ResolveAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
if (_cached is not null) return _cached;
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
_cached ??= await ResolveCoreAsync(ctx, ct);
|
||||
return _cached;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ClaudeCliLookupResult> ResolveCoreAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var resolved = ExecutableResolver.Resolve(ctx.ClaudeBin, PathOverride, PathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return new ClaudeCliLookupResult(null, ExitCode: -1, Output: string.Empty, ParsedVersion: null);
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
|
||||
var (exitCode, output) = await ProcessRunner.RunAsync(fileName, arguments, null, ct);
|
||||
var parsedVersion = exitCode == 0 ? ParseVersion(output) : null;
|
||||
|
||||
return new ClaudeCliLookupResult(resolved, exitCode, output, parsedVersion);
|
||||
}
|
||||
|
||||
private static Version? ParseVersion(string output) =>
|
||||
VersionPattern.Match(output) is { Success: true } match && Version.TryParse(match.Value, out var version)
|
||||
? version
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>Gates the CLI flags ClaudeArgsBuilder relies on — blocking.</summary>
|
||||
public sealed class ClaudeVersionCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-version";
|
||||
|
||||
/// <summary>
|
||||
/// Newest version confirmed to work end-to-end with every flag ClaudeDo uses (--permission-mode
|
||||
/// auto, --effort, --agents, --json-schema, --append-system-prompt, --output-format stream-json
|
||||
/// --verbose, --resume, mcp add --transport http --scope user). A verified floor, not a proven
|
||||
/// theoretical minimum — see docs/explore-notes/installer-preflight.md §3.
|
||||
/// </summary>
|
||||
public static readonly Version MinimumVersion = new(2, 1, 220);
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public ClaudeVersionCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Error;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
"The claude CLI was not found — version could not be checked.");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
$"Could not parse a version from '{lookup.Output.Trim()}'.");
|
||||
}
|
||||
|
||||
if (lookup.ParsedVersion < MinimumVersion)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
|
||||
"checks.claudeVersion.hint");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public sealed record EnvironmentCheckReport(IReadOnlyList<CheckResult> Results)
|
||||
{
|
||||
public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed);
|
||||
}
|
||||
|
||||
public sealed class EnvironmentCheckService
|
||||
{
|
||||
private readonly IReadOnlyList<IEnvironmentCheck> _checks;
|
||||
|
||||
public EnvironmentCheckService(IEnumerable<IEnvironmentCheck> checks)
|
||||
{
|
||||
_checks = checks.ToList();
|
||||
}
|
||||
|
||||
public async Task<EnvironmentCheckReport> RunAllAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return new EnvironmentCheckReport(results);
|
||||
}
|
||||
|
||||
private static async Task<CheckResult> RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await check.RunAsync(ctx, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
public interface IEnvironmentCheck
|
||||
{
|
||||
string Id { get; }
|
||||
CheckSeverity Severity { get; }
|
||||
Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct);
|
||||
}
|
||||
@@ -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,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Static-only check per docs/explore-notes/installer-preflight.md §2: real auto-mode eligibility
|
||||
/// (org/model/plan) has no cheap detectable signal, so this only confirms the CLI's argument
|
||||
/// parser still recognizes "auto" as a --permission-mode choice. A warning, not a blocker — a
|
||||
/// too-old CLI just degrades to acceptEdits/default with more prompts.
|
||||
/// </summary>
|
||||
public sealed class PermissionModeAutoCheck : IEnvironmentCheck
|
||||
{
|
||||
public const string CheckId = "claude-permission-mode-auto";
|
||||
|
||||
private static readonly Regex AutoWord = new(@"\bauto\b", RegexOptions.Compiled);
|
||||
private const int SearchWindow = 300;
|
||||
|
||||
private readonly ClaudeCliLookup _lookup;
|
||||
|
||||
public PermissionModeAutoCheck(ClaudeCliLookup lookup)
|
||||
{
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
public string Id => CheckId;
|
||||
public CheckSeverity Severity => CheckSeverity.Warning;
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"The claude CLI was not found — permission-mode support could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
|
||||
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"Could not determine whether --permission-mode auto is supported.");
|
||||
}
|
||||
|
||||
if (AutoIsListed(output))
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"--permission-mode auto is recognized by this CLI.");
|
||||
}
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"This claude CLI version does not list 'auto' as a --permission-mode choice.",
|
||||
"checks.permissionModeAuto.hint");
|
||||
}
|
||||
|
||||
private static bool AutoIsListed(string helpOutput)
|
||||
{
|
||||
var index = helpOutput.IndexOf("--permission-mode", StringComparison.Ordinal);
|
||||
if (index < 0) return false;
|
||||
|
||||
var windowEnd = Math.Min(helpOutput.Length, index + SearchWindow);
|
||||
return AutoWord.IsMatch(helpOutput[index..windowEnd]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public sealed class ExecutableResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public ExecutableResolverTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"claudedo_exeresolve_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private string MakeDir(string name)
|
||||
{
|
||||
var dir = Path.Combine(_root, name);
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static void Touch(string path) => File.WriteAllText(path, "");
|
||||
|
||||
[Fact]
|
||||
public void Command_with_directory_separator_is_treated_as_path()
|
||||
{
|
||||
var dir = MakeDir("bin1");
|
||||
var exePath = Path.Combine(dir, "myclaude.exe");
|
||||
Touch(exePath);
|
||||
|
||||
var result = ExecutableResolver.Resolve(exePath, pathOverride: "C:\\does\\not\\matter");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(exePath, result!.Path);
|
||||
Assert.False(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Path_like_command_without_extension_resolves_via_pathext()
|
||||
{
|
||||
var dir = MakeDir("bin2");
|
||||
var cmdPath = Path.Combine(dir, "myclaude.cmd");
|
||||
Touch(cmdPath);
|
||||
var commandWithoutExt = Path.Combine(dir, "myclaude");
|
||||
|
||||
var result = ExecutableResolver.Resolve(commandWithoutExt, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(cmdPath, result!.Path);
|
||||
Assert.True(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmd_file_in_path_is_found_when_no_exe_exists()
|
||||
{
|
||||
var dir = MakeDir("bin3");
|
||||
Touch(Path.Combine(dir, "claude.cmd"));
|
||||
|
||||
var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.Combine(dir, "claude.cmd"), result!.Path);
|
||||
Assert.True(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exe_wins_against_cmd_in_same_directory()
|
||||
{
|
||||
var dir = MakeDir("bin4");
|
||||
Touch(Path.Combine(dir, "claude.cmd"));
|
||||
Touch(Path.Combine(dir, "claude.exe"));
|
||||
|
||||
var result = ExecutableResolver.Resolve("claude", pathOverride: dir, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.Combine(dir, "claude.exe"), result!.Path);
|
||||
Assert.False(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directory_order_in_path_is_respected()
|
||||
{
|
||||
var dir1 = MakeDir("bin5a");
|
||||
var dir2 = MakeDir("bin5b");
|
||||
Touch(Path.Combine(dir1, "claude.cmd"));
|
||||
Touch(Path.Combine(dir2, "claude.exe"));
|
||||
|
||||
var pathOverride = $"{dir1}{Path.PathSeparator}{dir2}";
|
||||
var result = ExecutableResolver.Resolve("claude", pathOverride: pathOverride, pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(Path.Combine(dir1, "claude.cmd"), result!.Path);
|
||||
Assert.True(result.IsShim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unresolvable_command_returns_null()
|
||||
{
|
||||
var dir = MakeDir("bin6");
|
||||
|
||||
var result = ExecutableResolver.Resolve(
|
||||
"claudedo_totally_missing_cmd_9f3a1",
|
||||
pathOverride: dir,
|
||||
pathExtOverride: ".com;.exe;.bat;.cmd");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildShimStartInfo_uses_cmd_exe_and_quotes_paths_with_spaces()
|
||||
{
|
||||
const string shimPath = @"C:\Program Files\claude\claude.cmd";
|
||||
|
||||
var startInfo = ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "--version", "arg2" });
|
||||
|
||||
Assert.Equal("cmd.exe", startInfo.FileName);
|
||||
Assert.Contains("/c", startInfo.Arguments);
|
||||
Assert.Contains($"\"{shimPath}\"", startInfo.Arguments);
|
||||
Assert.Contains("--version", startInfo.Arguments);
|
||||
Assert.Contains("arg2", startInfo.Arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeAuthCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeAuthCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_in_reports_ok_without_sending_a_prompt()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.All(runner.Calls, call => Assert.DoesNotContain("-p ", call.Arguments));
|
||||
Assert.Contains(runner.Calls, call => call.Arguments.Contains("auth") && call.Arguments.Contains("status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logged_out_reports_failed()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "{\"loggedIn\":false}"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.NotNull(result.HintKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_output_reports_unknown_not_failed()
|
||||
{
|
||||
var runner = new FakeProcessRunner(
|
||||
(0, "2.1.230 (Claude Code)"),
|
||||
(0, "not json"));
|
||||
var check = new ClaudeAuthCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_missing_reports_unknown()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeauth_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeAuthCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeCliCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeCliCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudecli_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Found_as_exe_reports_ok_with_path_and_version_and_no_shim_detail()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.Contains(_dir, result.Message);
|
||||
Assert.Contains("2.1.230", result.Message);
|
||||
Assert.Null(result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Found_as_shim_flags_it_in_detail()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.cmd"), "");
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".cmd"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Not_found_reports_failed_naming_searched_locations()
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
// A distinctive, definitely-missing name — a plain "claude" would still resolve via
|
||||
// ExecutableResolver's fixed fallback dirs (e.g. %USERPROFILE%\.local\bin) on a machine
|
||||
// that has the real CLI installed, regardless of pathOverride.
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.Equal(CheckSeverity.Error, result.Severity);
|
||||
Assert.Contains(_dir, result.Message);
|
||||
Assert.NotNull(result.HintKey);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nonzero_exit_reports_failed()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
var runner = new FakeProcessRunner((1, "some error"));
|
||||
var check = new ClaudeCliCheck(new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe"));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeCliLookupTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeCliLookupTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Version_is_executed_only_once_across_repeated_calls()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
await lookup.ResolveAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Single(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_calls_still_run_version_only_once()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8)
|
||||
.Select(_ => lookup.ResolveAsync(new InstallContext(), CancellationToken.None)));
|
||||
|
||||
Assert.Single(runner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Version_is_executed_only_once_when_shared_across_all_four_checks()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230 (Claude Code)"));
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
var ctx = new InstallContext();
|
||||
|
||||
await new ClaudeCliCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new ClaudeVersionCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new ClaudeAuthCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
await new PermissionModeAutoCheck(lookup).RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
var versionCalls = runner.Calls.Count(c => c.Arguments.Contains("--version"));
|
||||
Assert.Equal(1, versionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_not_found_returns_null_resolved_without_running_a_process()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudelookup_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var lookup = new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe");
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await lookup.ResolveAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Resolved);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class ClaudeVersionCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClaudeVersionCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Theory]
|
||||
[InlineData("2.1.220 (Claude Code)", CheckStatus.Ok)] // exactly at the floor
|
||||
[InlineData("2.1.219 (Claude Code)", CheckStatus.Failed)] // one patch below the floor
|
||||
[InlineData("2.1.191", CheckStatus.Failed)]
|
||||
[InlineData("v2.1.191", CheckStatus.Failed)]
|
||||
[InlineData("2.10.0", CheckStatus.Ok)] // minor 10 > minor 1, not a lexical trap
|
||||
[InlineData("garbage output, no version here", CheckStatus.Unknown)]
|
||||
public async Task Version_parsing_and_gate(string versionOutput, CheckStatus expected)
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, versionOutput));
|
||||
var check = new ClaudeVersionCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(expected, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Minor_version_compares_numerically_not_lexically()
|
||||
{
|
||||
Assert.True(new Version(2, 10, 0) > new Version(2, 9, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_not_found_reports_unknown_not_failed()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdclaudeversion_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new ClaudeVersionCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
Assert.Empty(runner.Calls);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,88 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Checks;
|
||||
|
||||
public sealed class PermissionModeAutoCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
private const string HelpWithAuto =
|
||||
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")\n";
|
||||
|
||||
private const string HelpWithoutAuto =
|
||||
"Options:\n --permission-mode <mode> Permission mode (choices: \"acceptEdits\", \"bypassPermissions\", \"manual\")\n";
|
||||
|
||||
public PermissionModeAutoCheckTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"cdpermmode_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "claude.exe"), "");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeCliLookup MakeLookup(FakeProcessRunner runner) =>
|
||||
new(runner, pathOverride: _dir, pathExtOverride: ".exe");
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_listed_in_help_reports_ok()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithAuto));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Ok, result.Status);
|
||||
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_missing_from_help_reports_failed_with_hint()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (0, HelpWithoutAuto));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Failed, result.Status);
|
||||
Assert.Equal(CheckSeverity.Warning, result.Severity);
|
||||
Assert.NotNull(result.HintKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Help_not_determinable_reports_unknown()
|
||||
{
|
||||
var runner = new FakeProcessRunner((0, "2.1.230"), (1, ""));
|
||||
var check = new PermissionModeAutoCheck(MakeLookup(runner));
|
||||
|
||||
var result = await check.RunAsync(new InstallContext(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cli_missing_reports_unknown()
|
||||
{
|
||||
var emptyDir = Path.Combine(Path.GetTempPath(), $"cdpermmode_empty_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(emptyDir);
|
||||
try
|
||||
{
|
||||
var runner = new FakeProcessRunner();
|
||||
var check = new PermissionModeAutoCheck(new ClaudeCliLookup(runner, pathOverride: emptyDir, pathExtOverride: ".exe"));
|
||||
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
|
||||
|
||||
var result = await check.RunAsync(ctx, CancellationToken.None);
|
||||
|
||||
Assert.Equal(CheckStatus.Unknown, result.Status);
|
||||
Assert.Empty(runner.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(emptyDir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user