diff --git a/src/ClaudeDo.Data/Environment/ExecutableResolver.cs b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs new file mode 100644 index 00000000..7b6486d9 --- /dev/null +++ b/src/ClaudeDo.Data/Environment/ExecutableResolver.cs @@ -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); + +/// +/// 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. +/// +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; + } + + /// Expanded fallback directories tried when PATH search comes up empty (for diagnostics). + public static IReadOnlyList FallbackDirectories() => + FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList(); + + public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList arguments) + { + var parts = new List { "/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 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 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 ParsePathExt(string? pathExtOverride) + { + var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt; + return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static IReadOnlyList 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; +} diff --git a/src/ClaudeDo.Data/Paths.cs b/src/ClaudeDo.Data/Paths.cs index c55037fa..093caa4f 100644 --- a/src/ClaudeDo.Data/Paths.cs +++ b/src/ClaudeDo.Data/Paths.cs @@ -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); } diff --git a/src/ClaudeDo.Installer/Checks/CheckResult.cs b/src/ClaudeDo.Installer/Checks/CheckResult.cs new file mode 100644 index 00000000..15e612a0 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/CheckResult.cs @@ -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); +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeAuthCheck.cs b/src/ClaudeDo.Installer/Checks/ClaudeAuthCheck.cs new file mode 100644 index 00000000..9206f920 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeAuthCheck.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// +/// 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. +/// +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 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; + } +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeCliCheck.cs b/src/ClaudeDo.Installer/Checks/ClaudeCliCheck.cs new file mode 100644 index 00000000..9ba8f5a8 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeCliCheck.cs @@ -0,0 +1,54 @@ +using System.IO; +using ClaudeDo.Data.Environment; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Without the claude CLI, no task can ever run — blocking. +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 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 SearchedLocations() + { + var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? ""; + var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return pathEntries.Concat(ExecutableResolver.FallbackDirectories()); + } +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeCliLookup.cs b/src/ClaudeDo.Installer/Checks/ClaudeCliLookup.cs new file mode 100644 index 00000000..801027f8 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeCliLookup.cs @@ -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); + +/// +/// Resolves the claude CLI and runs `--version` at most once per install run, shared across +/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck. +/// +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 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 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; +} diff --git a/src/ClaudeDo.Installer/Checks/ClaudeVersionCheck.cs b/src/ClaudeDo.Installer/Checks/ClaudeVersionCheck.cs new file mode 100644 index 00000000..1d595dc8 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ClaudeVersionCheck.cs @@ -0,0 +1,54 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Gates the CLI flags ClaudeArgsBuilder relies on — blocking. +public sealed class ClaudeVersionCheck : IEnvironmentCheck +{ + public const string CheckId = "claude-version"; + + /// + /// 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. + /// + 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 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})."); + } +} diff --git a/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs b/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs new file mode 100644 index 00000000..f21cabc5 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs @@ -0,0 +1,37 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +public sealed record EnvironmentCheckReport(IReadOnlyList Results) +{ + public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed); +} + +public sealed class EnvironmentCheckService +{ + private readonly IReadOnlyList _checks; + + public EnvironmentCheckService(IEnumerable checks) + { + _checks = checks.ToList(); + } + + public async Task 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 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); + } + } +} diff --git a/src/ClaudeDo.Installer/Checks/GitCheck.cs b/src/ClaudeDo.Installer/Checks/GitCheck.cs new file mode 100644 index 00000000..71aa3994 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/GitCheck.cs @@ -0,0 +1,49 @@ +using System.Text.RegularExpressions; +using ClaudeDo.Data.Environment; +using ClaudeDo.Installer.Core; +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Checks; + +/// Without git, no task can run (worktrees) — blocking. +public sealed class GitCheck : IEnvironmentCheck +{ + public const string CheckId = "git"; + private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled); + + private readonly IProcessRunner _processRunner; + private readonly string? _pathOverride; + private readonly string? _pathExtOverride; + + public GitCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null) + { + _processRunner = processRunner; + _pathOverride = pathOverride; + _pathExtOverride = pathExtOverride; + } + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Error; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride); + if (resolved is null) + { + return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.", + "checks.git.hint", "https://git-scm.com/downloads"); + } + + var (fileName, arguments) = ProcessCommand.For(resolved, "--version"); + var (exitCode, output) = await _processRunner.RunAsync(fileName, arguments, null, ct); + + if (exitCode != 0) + { + return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.", + "checks.git.hint", "https://git-scm.com/downloads", output); + } + + var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim(); + return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}"); + } +} diff --git a/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs b/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs new file mode 100644 index 00000000..8a4a5a07 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/GitIdentityCheck.cs @@ -0,0 +1,60 @@ +using ClaudeDo.Data.Environment; +using ClaudeDo.Installer.Core; +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Checks; + +/// Trivial to fix after the fact (git config --global), and the Claude help-me button can resolve it — a warning, not a blocker. +public sealed class GitIdentityCheck : IEnvironmentCheck +{ + public const string CheckId = "git-identity"; + + private readonly IProcessRunner _processRunner; + private readonly string? _pathOverride; + private readonly string? _pathExtOverride; + + public GitIdentityCheck(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null) + { + _processRunner = processRunner; + _pathOverride = pathOverride; + _pathExtOverride = pathExtOverride; + } + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Warning; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride); + if (resolved is null) + { + return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked."); + } + + var name = await ReadConfigAsync(resolved, "user.name", ct); + var email = await ReadConfigAsync(resolved, "user.email", ct); + var hasName = !string.IsNullOrWhiteSpace(name); + var hasEmail = !string.IsNullOrWhiteSpace(email); + + if (hasName && hasEmail) + { + return CheckResult.Ok(Id, Severity, "checks.gitIdentity.title", $"{name} <{email}>"); + } + + var missing = (hasName, hasEmail) switch + { + (false, false) => "user.name, user.email", + (false, true) => "user.name", + _ => "user.email", + }; + + return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint"); + } + + private async Task ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct) + { + var (fileName, arguments) = ProcessCommand.For(resolved, "config", "--get", key); + var (_, output) = await _processRunner.RunAsync(fileName, arguments, null, ct); + return output.Trim(); + } +} diff --git a/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs b/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs new file mode 100644 index 00000000..1997dec7 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs @@ -0,0 +1,10 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +public interface IEnvironmentCheck +{ + string Id { get; } + CheckSeverity Severity { get; } + Task RunAsync(InstallContext ctx, CancellationToken ct); +} diff --git a/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs b/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs new file mode 100644 index 00000000..39e2a4e6 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/Interfaces/IPortOwnerResolver.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Installer.Checks.Interfaces; + +public interface IPortOwnerResolver +{ + Task FindOwningProcessNameAsync(int port, CancellationToken ct); +} diff --git a/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs b/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs new file mode 100644 index 00000000..0a4c3e26 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/NetstatPortOwnerResolver.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; +using ClaudeDo.Installer.Checks.Interfaces; +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Checks; + +public sealed class NetstatPortOwnerResolver : IPortOwnerResolver +{ + private readonly IProcessRunner _processRunner; + + public NetstatPortOwnerResolver(IProcessRunner processRunner) => _processRunner = processRunner; + + public async Task FindOwningProcessNameAsync(int port, CancellationToken ct) + { + var (exitCode, output) = await _processRunner.RunAsync("netstat.exe", "-ano -p TCP", null, ct); + if (exitCode != 0) return null; + + var suffix = $":{port}"; + foreach (var line in output.Split('\n')) + { + var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5) continue; + if (!parts[0].Equals("TCP", StringComparison.OrdinalIgnoreCase)) continue; + if (!parts[1].EndsWith(suffix, StringComparison.Ordinal)) continue; + if (!int.TryParse(parts[^1], out var pid)) continue; + + try { return Process.GetProcessById(pid).ProcessName; } + catch { return null; } + } + + return null; + } +} diff --git a/src/ClaudeDo.Installer/Checks/PermissionModeAutoCheck.cs b/src/ClaudeDo.Installer/Checks/PermissionModeAutoCheck.cs new file mode 100644 index 00000000..72f2d5e7 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/PermissionModeAutoCheck.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// +/// 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. +/// +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 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]); + } +} diff --git a/src/ClaudeDo.Installer/Checks/PortCheck.cs b/src/ClaudeDo.Installer/Checks/PortCheck.cs new file mode 100644 index 00000000..07f1ade1 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/PortCheck.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Sockets; +using ClaudeDo.Installer.Checks.Interfaces; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Both ports are configurable in the installer settings, so a conflict is only a warning. +public sealed class PortCheck : IEnvironmentCheck +{ + public const string CheckId = "ports"; + + private readonly IPortOwnerResolver _portOwnerResolver; + + public PortCheck(IPortOwnerResolver portOwnerResolver) => _portOwnerResolver = portOwnerResolver; + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Warning; + + public async Task RunAsync(InstallContext ctx, CancellationToken ct) + { + var ports = new (string Label, int Port)[] + { + ("SignalR", ctx.SignalRPort), + ("External MCP", ctx.ExternalMcpPort), + }; + + var blocked = new List(); + var ownWorker = new List(); + + foreach (var (label, port) in ports) + { + if (IsFree(port)) continue; + + var owner = await _portOwnerResolver.FindOwningProcessNameAsync(port, ct); + if (owner is not null && owner.Contains("ClaudeDo.Worker", StringComparison.OrdinalIgnoreCase)) + { + ownWorker.Add($"{label} ({port})"); + continue; + } + + blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'."); + } + + if (blocked.Count > 0) + { + return CheckResult.Fail(Id, Severity, "checks.ports.title", string.Join(" ", blocked), "checks.ports.hint"); + } + + if (ownWorker.Count > 0) + { + return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}."); + } + + return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available."); + } + + private static bool IsFree(int port) + { + try + { + using var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + return true; + } + catch (SocketException) + { + return false; + } + } +} diff --git a/src/ClaudeDo.Installer/Checks/ProcessCommand.cs b/src/ClaudeDo.Installer/Checks/ProcessCommand.cs new file mode 100644 index 00000000..bef0ca4b --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/ProcessCommand.cs @@ -0,0 +1,20 @@ +using ClaudeDo.Data.Environment; + +namespace ClaudeDo.Installer.Checks; + +/// Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe. +internal static class ProcessCommand +{ + public static (string FileName, string Arguments) For(ResolvedExecutable resolved, params string[] args) + { + if (resolved.IsShim) + { + var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, args); + return (shim.FileName, shim.Arguments); + } + + return (resolved.Path, string.Join(' ', args.Select(Quote))); + } + + private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value; +} diff --git a/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs b/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs new file mode 100644 index 00000000..fe396c2e --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/WriteAccessCheck.cs @@ -0,0 +1,62 @@ +using System.IO; +using ClaudeDo.Data; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +/// Without write access nothing can be installed — blocking. +public sealed class WriteAccessCheck : IEnvironmentCheck +{ + public const string CheckId = "write-access"; + + public string Id => CheckId; + public CheckSeverity Severity => CheckSeverity.Error; + + public Task RunAsync(InstallContext ctx, CancellationToken ct) + { + foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() }) + { + var error = TryWrite(target); + if (error is not null) + { + return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title", + $"Cannot write to '{target}': {error}", "checks.writeAccess.hint")); + } + } + + return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable.")); + } + + private static string? TryWrite(string path) + { + var probeDir = FirstExistingParent(path); + var probeFile = Path.Combine(probeDir, $".claudedo-write-check-{Guid.NewGuid():N}.tmp"); + + try + { + File.WriteAllText(probeFile, string.Empty); + return null; + } + catch (Exception ex) + { + return ex.Message; + } + finally + { + try { File.Delete(probeFile); } catch { /* best-effort cleanup */ } + } + } + + private static string FirstExistingParent(string path) + { + var current = Path.GetFullPath(path); + while (!Directory.Exists(current)) + { + var parent = Path.GetDirectoryName(current); + if (string.IsNullOrEmpty(parent) || parent == current) break; + current = parent; + } + + return current; + } +} diff --git a/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs b/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs new file mode 100644 index 00000000..5dbc20d5 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/Interfaces/IProcessRunner.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Installer.Core.Interfaces; + +public interface IProcessRunner +{ + Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct); +} diff --git a/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs b/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs new file mode 100644 index 00000000..d1ec89e8 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/ProcessRunnerAdapter.cs @@ -0,0 +1,9 @@ +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Core; + +public sealed class ProcessRunnerAdapter : IProcessRunner +{ + public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) => + ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct); +} diff --git a/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs b/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs new file mode 100644 index 00000000..2160379a --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ExecutableResolverTests.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeAuthCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeAuthCheckTests.cs new file mode 100644 index 00000000..6061388e --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeAuthCheckTests.cs @@ -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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliCheckTests.cs new file mode 100644 index 00000000..da89313e --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliCheckTests.cs @@ -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); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliLookupTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliLookupTests.cs new file mode 100644 index 00000000..a062dd9f --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeCliLookupTests.cs @@ -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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/ClaudeVersionCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeVersionCheckTests.cs new file mode 100644 index 00000000..c21d7fcd --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/ClaudeVersionCheckTests.cs @@ -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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs b/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs new file mode 100644 index 00000000..9095f6ba --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/FakeProcessRunner.cs @@ -0,0 +1,22 @@ +using ClaudeDo.Installer.Core.Interfaces; + +namespace ClaudeDo.Installer.Tests.Checks; + +internal sealed class FakeProcessRunner : IProcessRunner +{ + private readonly Queue<(int ExitCode, string Output)> _responses; + + public FakeProcessRunner(params (int ExitCode, string Output)[] responses) + { + _responses = new Queue<(int, string)>(responses); + } + + public List<(string FileName, string Arguments)> Calls { get; } = new(); + + public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) + { + Calls.Add((fileName, arguments)); + var response = _responses.Count > 0 ? _responses.Dequeue() : (0, ""); + return Task.FromResult(response); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs new file mode 100644 index 00000000..aae7bd12 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/GitCheckTests.cs @@ -0,0 +1,66 @@ +using ClaudeDo.Installer.Checks; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests.Checks; + +public sealed class GitCheckTests : IDisposable +{ + private readonly string _dir; + + public GitCheckTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"cdgitcheck_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private string MakeGitExe() + { + var path = Path.Combine(_dir, "git.exe"); + File.WriteAllText(path, ""); + return path; + } + + [Fact] + public async Task Git_present_with_parseable_version_reports_ok() + { + MakeGitExe(); + var runner = new FakeProcessRunner((0, "git version 2.43.0.windows.1\n")); + var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Ok, result.Status); + Assert.Contains("2.43.0", result.Message); + Assert.Contains(_dir, result.Message); + } + + [Fact] + public async Task Git_not_found_on_path_reports_failed() + { + var runner = new FakeProcessRunner(); + var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + Assert.Equal(CheckSeverity.Error, result.Severity); + Assert.Empty(runner.Calls); + } + + [Fact] + public async Task Git_nonzero_exit_reports_failed() + { + MakeGitExe(); + var runner = new FakeProcessRunner((1, "some error")); + var check = new GitCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs new file mode 100644 index 00000000..84b431a3 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/GitIdentityCheckTests.cs @@ -0,0 +1,88 @@ +using ClaudeDo.Installer.Checks; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests.Checks; + +public sealed class GitIdentityCheckTests : IDisposable +{ + private readonly string _dir; + + public GitIdentityCheckTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"cdgitid_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private void MakeGitExe() => File.WriteAllText(Path.Combine(_dir, "git.exe"), ""); + + [Fact] + public async Task Both_set_reports_ok() + { + MakeGitExe(); + var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (0, "mika@example.com\n")); + var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Ok, result.Status); + } + + [Fact] + public async Task Only_name_set_reports_failed_naming_email() + { + MakeGitExe(); + var runner = new FakeProcessRunner((0, "Mika Kuns\n"), (1, "")); + var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + Assert.Contains("user.email", result.Message); + Assert.DoesNotContain("user.name", result.Message); + } + + [Fact] + public async Task Only_email_set_reports_failed_naming_name() + { + MakeGitExe(); + var runner = new FakeProcessRunner((1, ""), (0, "mika@example.com\n")); + var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + Assert.Contains("user.name", result.Message); + Assert.DoesNotContain("user.email", result.Message); + } + + [Fact] + public async Task Neither_set_reports_failed_naming_both() + { + MakeGitExe(); + var runner = new FakeProcessRunner((1, ""), (1, "")); + var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + Assert.Contains("user.name", result.Message); + Assert.Contains("user.email", result.Message); + } + + [Fact] + public async Task Git_missing_reports_unknown_not_failed() + { + var runner = new FakeProcessRunner(); + var check = new GitIdentityCheck(runner, pathOverride: _dir, pathExtOverride: ".exe"); + + var result = await check.RunAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(CheckStatus.Unknown, result.Status); + Assert.Empty(runner.Calls); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/PermissionModeAutoCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/PermissionModeAutoCheckTests.cs new file mode 100644 index 00000000..7894f955 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/PermissionModeAutoCheckTests.cs @@ -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 Permission mode (choices: \"acceptEdits\", \"auto\", \"bypassPermissions\", \"manual\", \"dontAsk\", \"plan\")\n"; + + private const string HelpWithoutAuto = + "Options:\n --permission-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); + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs new file mode 100644 index 00000000..2a256f0c --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/PortCheckTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Net.Sockets; +using ClaudeDo.Installer.Checks; +using ClaudeDo.Installer.Checks.Interfaces; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests.Checks; + +public sealed class PortCheckTests +{ + private sealed class FakeOwnerResolver : IPortOwnerResolver + { + private readonly string? _owner; + public FakeOwnerResolver(string? owner) => _owner = owner; + public Task FindOwningProcessNameAsync(int port, CancellationToken ct) => Task.FromResult(_owner); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + [Fact] + public async Task Both_ports_free_reports_ok() + { + var ctx = new InstallContext { SignalRPort = GetFreePort(), ExternalMcpPort = GetFreePort() }; + var check = new PortCheck(new FakeOwnerResolver(null)); + + var result = await check.RunAsync(ctx, CancellationToken.None); + + Assert.Equal(CheckStatus.Ok, result.Status); + } + + [Fact] + public async Task Port_in_use_by_other_process_reports_failed() + { + var port = GetFreePort(); + using var occupying = new TcpListener(IPAddress.Loopback, port); + occupying.Start(); + var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() }; + var check = new PortCheck(new FakeOwnerResolver("SomeOtherApp")); + + var result = await check.RunAsync(ctx, CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + Assert.Equal(CheckSeverity.Warning, result.Severity); + Assert.Contains("SomeOtherApp", result.Message); + } + + [Fact] + public async Task Port_in_use_by_own_worker_reports_ok() + { + var port = GetFreePort(); + using var occupying = new TcpListener(IPAddress.Loopback, port); + occupying.Start(); + var ctx = new InstallContext { SignalRPort = port, ExternalMcpPort = GetFreePort() }; + var check = new PortCheck(new FakeOwnerResolver("ClaudeDo.Worker")); + + var result = await check.RunAsync(ctx, CancellationToken.None); + + Assert.Equal(CheckStatus.Ok, result.Status); + Assert.Contains("ClaudeDo Worker", result.Message); + } +} diff --git a/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs b/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs new file mode 100644 index 00000000..e0734afa --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/Checks/WriteAccessCheckTests.cs @@ -0,0 +1,80 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using ClaudeDo.Installer.Checks; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests.Checks; + +public sealed class WriteAccessCheckTests : IDisposable +{ + private readonly List _cleanupDirs = new(); + + private string TempDir() + { + var dir = Path.Combine(Path.GetTempPath(), $"cdwriteaccess_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + _cleanupDirs.Add(dir); + return dir; + } + + public void Dispose() + { + foreach (var dir in _cleanupDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + [Fact] + public async Task Writable_directory_reports_ok() + { + var dir = TempDir(); + var check = new WriteAccessCheck(); + + var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None); + + Assert.Equal(CheckStatus.Ok, result.Status); + } + + [Fact] + public async Task NonExistent_path_under_writable_parent_reports_ok() + { + var parent = TempDir(); + var target = Path.Combine(parent, "does", "not", "exist"); + var check = new WriteAccessCheck(); + + var result = await check.RunAsync(new InstallContext { InstallDirectory = target }, CancellationToken.None); + + Assert.Equal(CheckStatus.Ok, result.Status); + } + + [Fact] + public async Task Unwritable_directory_reports_failed_with_no_leftover_file() + { + var dir = TempDir(); + var dirInfo = new DirectoryInfo(dir); + var identity = WindowsIdentity.GetCurrent().User!; + var denyRule = new FileSystemAccessRule(identity, FileSystemRights.CreateFiles | FileSystemRights.WriteData, AccessControlType.Deny); + + var acl = dirInfo.GetAccessControl(); + acl.AddAccessRule(denyRule); + dirInfo.SetAccessControl(acl); + + try + { + var check = new WriteAccessCheck(); + + var result = await check.RunAsync(new InstallContext { InstallDirectory = dir }, CancellationToken.None); + + Assert.Equal(CheckStatus.Failed, result.Status); + Assert.Equal(CheckSeverity.Error, result.Severity); + Assert.Contains(dir, result.Message); + Assert.Empty(Directory.GetFiles(dir)); + } + finally + { + acl.RemoveAccessRule(denyRule); + dirInfo.SetAccessControl(acl); + } + } +}