From 3ace09543c430619729bf8abf39608954b146bbc Mon Sep 17 00:00:00 2001 From: BotNexus Test Date: Thu, 21 May 2026 23:33:05 -0700 Subject: [PATCH] feat(cli): add gateway install/uninstall commands for OS service registration - IGatewayServiceInstaller interface + GatewayServiceInstaller impl - Linux: writes systemd unit to /etc/systemd/system/botnexus.service - Windows: registers via sc.exe create/start/stop/delete - macOS: writes launchd plist to ~/Library/LaunchAgents/ - GatewayCommand: install + uninstall subcommands - Gateway Program.cs: UseSystemd() + UseWindowsService() (no-op in CLI mode) - 13 new tests covering unit-file/plist content and arg validation Closes #120 --- Directory.Packages.props | 2 + .../BotNexus.Cli/Commands/GatewayCommand.cs | 101 +++++- src/gateway/BotNexus.Cli/Program.cs | 1 + .../Services/GatewayServiceInstaller.cs | 323 ++++++++++++++++++ .../Services/IGatewayServiceInstaller.cs | 39 +++ .../BotNexus.Gateway.Api.csproj | 2 + src/gateway/BotNexus.Gateway.Api/Program.cs | 6 +- .../Services/GatewayServiceInstallerTests.cs | 170 +++++++++ 8 files changed, 641 insertions(+), 3 deletions(-) create mode 100644 src/gateway/BotNexus.Cli/Services/GatewayServiceInstaller.cs create mode 100644 src/gateway/BotNexus.Cli/Services/IGatewayServiceInstaller.cs create mode 100644 tests/gateway/BotNexus.Cli.Tests/Services/GatewayServiceInstallerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 55de64f4..aa163a44 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,8 @@ + + diff --git a/src/gateway/BotNexus.Cli/Commands/GatewayCommand.cs b/src/gateway/BotNexus.Cli/Commands/GatewayCommand.cs index 9cb3af78..af1d78c9 100644 --- a/src/gateway/BotNexus.Cli/Commands/GatewayCommand.cs +++ b/src/gateway/BotNexus.Cli/Commands/GatewayCommand.cs @@ -6,15 +6,19 @@ namespace BotNexus.Cli.Commands; /// -/// Gateway lifecycle management commands: start, stop, status, restart. +/// Gateway lifecycle management commands: start, stop, status, restart, install, uninstall. /// internal sealed class GatewayCommand { private readonly IGatewayProcessManager _processManager; + private readonly IGatewayServiceInstaller _serviceInstaller; - public GatewayCommand(IGatewayProcessManager processManager) + public GatewayCommand( + IGatewayProcessManager processManager, + IGatewayServiceInstaller serviceInstaller) { _processManager = processManager; + _serviceInstaller = serviceInstaller; } public Command Build(Option verboseOption) @@ -94,10 +98,40 @@ public Command Build(Option verboseOption) context.ExitCode = await RestartAsync(repoRoot, home, port, verbose, context.GetCancellationToken()); }); + // Install command + var installPortOption = new Option("--port", () => 5005, "Port to listen on."); + var installSourceOption = new Option("--source", () => null, "Path to the BotNexus repository root. Defaults to ~/botnexus."); + var installTargetOption = new Option("--target", () => null, "BotNexus home directory. Defaults to ~/.botnexus."); + var installCommand = new Command("install", "Install the gateway as an OS service (systemd / Windows Service / launchd)") + { + installPortOption, + installSourceOption, + installTargetOption + }; + installCommand.SetHandler(async context => + { + var port = context.ParseResult.GetValueForOption(installPortOption); + var source = context.ParseResult.GetValueForOption(installSourceOption); + var target = context.ParseResult.GetValueForOption(installTargetOption); + var verbose = context.ParseResult.GetValueForOption(verboseOption); + var repoRoot = CliPaths.ResolveSource(source); + var home = CliPaths.ResolveTarget(target); + context.ExitCode = await InstallServiceAsync(repoRoot, home, port, verbose, context.GetCancellationToken()); + }); + + // Uninstall command + var uninstallCommand = new Command("uninstall", "Remove the gateway OS service registration"); + uninstallCommand.SetHandler(async context => + { + context.ExitCode = await UninstallServiceAsync(context.GetCancellationToken()); + }); + command.AddCommand(startCommand); command.AddCommand(stopCommand); command.AddCommand(statusCommand); command.AddCommand(restartCommand); + command.AddCommand(installCommand); + command.AddCommand(uninstallCommand); return command; } @@ -429,4 +463,67 @@ private static string FormatUptime(TimeSpan uptime) return $"{(int)uptime.TotalMinutes}m {uptime.Seconds}s"; return $"{uptime.Seconds}s"; } + + private async Task InstallServiceAsync( + string repoRoot, string home, int port, bool verbose, CancellationToken cancellationToken) + { + var status = await _serviceInstaller.GetStatusAsync(cancellationToken); + if (status.IsInstalled) + { + AnsiConsole.MarkupLine($"[yellow]\u26a0[/] Gateway service is already installed ({status.Platform})."); + return 0; + } + + var gatewayDll = Path.Combine( + repoRoot, "src", "gateway", "BotNexus.Gateway.Api", + "bin", "Release", "net10.0", "BotNexus.Gateway.Api.dll"); + + if (!File.Exists(gatewayDll)) + { + AnsiConsole.MarkupLine($"[red]\u2715[/] Release build not found. Run [dim]botnexus build[/] first."); + AnsiConsole.MarkupLine($" Expected: [dim]{Markup.Escape(gatewayDll)}[/]"); + return 1; + } + + AnsiConsole.MarkupLine($"[blue][[install]][/] Installing gateway as OS service ({status.Platform})..."); + + var result = await _serviceInstaller.InstallAsync(gatewayDll, home, port, cancellationToken); + + if (result.Success) + { + AnsiConsole.MarkupLine($"[green]\u2713[/] {Markup.Escape(result.Message)}"); + AnsiConsole.MarkupLine($" [dim]Use [/][yellow]botnexus gateway status[/][dim] to verify.[/]"); + return 0; + } + else + { + AnsiConsole.MarkupLine($"[red]\u2715[/] {Markup.Escape(result.Message)}"); + return 1; + } + } + + private async Task UninstallServiceAsync(CancellationToken cancellationToken) + { + var status = await _serviceInstaller.GetStatusAsync(cancellationToken); + if (!status.IsInstalled) + { + AnsiConsole.MarkupLine($"[dim]\u25cf Gateway service is not installed.[/]"); + return 0; + } + + AnsiConsole.MarkupLine($"[blue][[uninstall]][/] Removing gateway OS service..."); + + var result = await _serviceInstaller.UninstallAsync(cancellationToken); + + if (result.Success) + { + AnsiConsole.MarkupLine($"[green]\u2713[/] {Markup.Escape(result.Message)}"); + return 0; + } + else + { + AnsiConsole.MarkupLine($"[red]\u2715[/] {Markup.Escape(result.Message)}"); + return 1; + } + } } diff --git a/src/gateway/BotNexus.Cli/Program.cs b/src/gateway/BotNexus.Cli/Program.cs index 026c26d8..f07dd2bd 100644 --- a/src/gateway/BotNexus.Cli/Program.cs +++ b/src/gateway/BotNexus.Cli/Program.cs @@ -12,6 +12,7 @@ .AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Warning)) .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/gateway/BotNexus.Cli/Services/GatewayServiceInstaller.cs b/src/gateway/BotNexus.Cli/Services/GatewayServiceInstaller.cs new file mode 100644 index 00000000..dc266eee --- /dev/null +++ b/src/gateway/BotNexus.Cli/Services/GatewayServiceInstaller.cs @@ -0,0 +1,323 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace BotNexus.Cli.Services; + +/// +/// Installs and uninstalls the BotNexus Gateway as a native OS service. +/// +/// Linux — writes a systemd unit file under /etc/systemd/system/ and runs systemctl enable --now. +/// Windows — uses sc.exe to register the service. +/// macOS — writes a launchd plist to ~/Library/LaunchAgents/ and loads it. +/// +/// +public sealed class GatewayServiceInstaller : IGatewayServiceInstaller +{ + internal const string ServiceName = "botnexus"; + internal const string SystemdUnitPath = "/etc/systemd/system/botnexus.service"; + internal const string LaunchAgentDir = "Library/LaunchAgents"; + internal const string LaunchAgentPlistName = "ai.botnexus.gateway"; + + /// + public Task GetStatusAsync(CancellationToken cancellationToken = default) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + var installed = File.Exists(SystemdUnitPath); + return Task.FromResult(new ServiceInstallStatus(installed, "linux", ServiceName)); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var installed = WindowsServiceExists(); + return Task.FromResult(new ServiceInstallStatus(installed, "windows", ServiceName)); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var plistPath = Path.Combine(home, LaunchAgentDir, $"{LaunchAgentPlistName}.plist"); + var installed = File.Exists(plistPath); + return Task.FromResult(new ServiceInstallStatus(installed, "macos", LaunchAgentPlistName)); + } + + return Task.FromResult(new ServiceInstallStatus(false, "unknown", null)); + } + + /// + public async Task InstallAsync( + string executablePath, + string homePath, + int port = 5005, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(executablePath)) + return new ServiceInstallResult(false, "Executable path is required."); + if (!File.Exists(executablePath)) + return new ServiceInstallResult(false, $"Executable not found: {executablePath}"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return await InstallSystemdAsync(executablePath, homePath, port, cancellationToken); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return await InstallWindowsServiceAsync(executablePath, homePath, port, cancellationToken); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return await InstallLaunchdAsync(executablePath, homePath, port, cancellationToken); + + return new ServiceInstallResult(false, "OS service installation is not supported on this platform."); + } + + /// + public async Task UninstallAsync(CancellationToken cancellationToken = default) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return await UninstallSystemdAsync(cancellationToken); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return await UninstallWindowsServiceAsync(cancellationToken); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return await UninstallLaunchdAsync(cancellationToken); + + return new ServiceInstallResult(false, "OS service uninstall is not supported on this platform."); + } + + // ── Linux / systemd ────────────────────────────────────────────────────── + + /// Generates the systemd unit file content for the gateway service. + internal static string BuildSystemdUnit(string executablePath, string homePath, int port) + { + var dotnetPath = ResolveDotnetPath(); + return $""" +[Unit] +Description=BotNexus Gateway +After=network.target + +[Service] +Type=notify +ExecStart={dotnetPath} "{executablePath}" --urls "http://localhost:{port}" +Environment=BOTNEXUS_HOME={homePath} +Restart=on-failure +RestartSec=5s +WorkingDirectory={Path.GetDirectoryName(executablePath) ?? "/"} + +[Install] +WantedBy=multi-user.target +"""; + } + + private static async Task InstallSystemdAsync( + string executablePath, string homePath, int port, CancellationToken cancellationToken) + { + var unitContent = BuildSystemdUnit(executablePath, homePath, port); + try + { + await File.WriteAllTextAsync(SystemdUnitPath, unitContent, cancellationToken); + } + catch (Exception ex) + { + return new ServiceInstallResult(false, + $"Failed to write unit file (run with sudo): {ex.Message}"); + } + + var (code, output) = await RunProcessAsync("systemctl", "enable --now botnexus", cancellationToken); + if (code != 0) + return new ServiceInstallResult(false, $"systemctl enable --now failed: {output}"); + + return new ServiceInstallResult(true, $"Service installed and started. Unit: {SystemdUnitPath}"); + } + + private static async Task UninstallSystemdAsync(CancellationToken cancellationToken) + { + await RunProcessAsync("systemctl", "disable --now botnexus", cancellationToken); + + if (File.Exists(SystemdUnitPath)) + { + try { File.Delete(SystemdUnitPath); } + catch (Exception ex) + { + return new ServiceInstallResult(false, + $"Failed to delete unit file (run with sudo): {ex.Message}"); + } + } + + await RunProcessAsync("systemctl", "daemon-reload", cancellationToken); + return new ServiceInstallResult(true, "Service stopped, disabled, and unit file removed."); + } + + // ── Windows / sc.exe ───────────────────────────────────────────────────── + + private static async Task InstallWindowsServiceAsync( + string executablePath, string homePath, int port, CancellationToken cancellationToken) + { + var dotnetPath = ResolveDotnetPath(); + var binPath = $"{dotnetPath} \"{executablePath}\" --urls \"http://localhost:{port}\""; + + var (code, output) = await RunProcessAsync( + "sc.exe", + $"create {ServiceName} binPath= \"{binPath}\" start= auto DisplayName= \"BotNexus Gateway\"", + cancellationToken); + + if (code != 0) + return new ServiceInstallResult(false, $"sc create failed: {output}"); + + var (startCode, startOutput) = await RunProcessAsync( + "sc.exe", $"start {ServiceName}", cancellationToken); + + if (startCode != 0) + return new ServiceInstallResult(false, + $"Service registered but failed to start: {startOutput}"); + + return new ServiceInstallResult(true, $"Service '{ServiceName}' installed and started."); + } + + private static async Task UninstallWindowsServiceAsync( + CancellationToken cancellationToken) + { + await RunProcessAsync("sc.exe", $"stop {ServiceName}", cancellationToken); + var (code, output) = await RunProcessAsync( + "sc.exe", $"delete {ServiceName}", cancellationToken); + + if (code != 0) + return new ServiceInstallResult(false, $"sc delete failed: {output}"); + + return new ServiceInstallResult(true, $"Service '{ServiceName}' stopped and removed."); + } + + private static bool WindowsServiceExists() + { + var (exitCode, _) = RunProcessAsync("sc.exe", $"query {ServiceName}", + CancellationToken.None).GetAwaiter().GetResult(); + return exitCode == 0; + } + + // ── macOS / launchd ────────────────────────────────────────────────────── + + /// Generates the launchd plist content for the gateway service. + internal static string BuildLaunchAgentPlist(string executablePath, string homePath, int port) + { + var dotnetPath = ResolveDotnetPath(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var logDir = Path.Combine(home, ".botnexus", "logs"); + return $""" + + + + + Label + {LaunchAgentPlistName} + ProgramArguments + + {dotnetPath} + {executablePath} + --urls + http://localhost:{port} + + EnvironmentVariables + + BOTNEXUS_HOME + {homePath} + + RunAtLoad + + KeepAlive + + StandardOutPath + {logDir}/gateway-launchd.log + StandardErrorPath + {logDir}/gateway-launchd-error.log + + +"""; + } + + private static async Task InstallLaunchdAsync( + string executablePath, string homePath, int port, CancellationToken cancellationToken) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var launchAgentsDir = Path.Combine(home, LaunchAgentDir); + var plistPath = Path.Combine(launchAgentsDir, $"{LaunchAgentPlistName}.plist"); + + try + { + Directory.CreateDirectory(launchAgentsDir); + await File.WriteAllTextAsync(plistPath, + BuildLaunchAgentPlist(executablePath, homePath, port), cancellationToken); + } + catch (Exception ex) + { + return new ServiceInstallResult(false, $"Failed to write plist: {ex.Message}"); + } + + var (code, output) = await RunProcessAsync( + "launchctl", $"load -w \"{plistPath}\"", cancellationToken); + + if (code != 0) + return new ServiceInstallResult(false, $"launchctl load failed: {output}"); + + return new ServiceInstallResult(true, $"LaunchAgent installed. Plist: {plistPath}"); + } + + private static async Task UninstallLaunchdAsync( + CancellationToken cancellationToken) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var plistPath = Path.Combine(home, LaunchAgentDir, $"{LaunchAgentPlistName}.plist"); + + await RunProcessAsync("launchctl", $"unload -w \"{plistPath}\"", cancellationToken); + + if (File.Exists(plistPath)) + { + try { File.Delete(plistPath); } + catch (Exception ex) + { + return new ServiceInstallResult(false, $"Failed to delete plist: {ex.Message}"); + } + } + + return new ServiceInstallResult(true, "LaunchAgent unloaded and plist removed."); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + internal static string ResolveDotnetPath() + { + var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + if (!string.IsNullOrWhiteSpace(dotnetRoot)) + { + var exe = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + var candidate = Path.Combine(dotnetRoot, exe); + if (File.Exists(candidate)) + return candidate; + } + + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + } + + internal static async Task<(int ExitCode, string Output)> RunProcessAsync( + string fileName, string arguments, CancellationToken cancellationToken) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + process.Start(); + var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = await process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + var combined = string.IsNullOrWhiteSpace(stderr) ? stdout.Trim() : stderr.Trim(); + return (process.ExitCode, combined); + } +} diff --git a/src/gateway/BotNexus.Cli/Services/IGatewayServiceInstaller.cs b/src/gateway/BotNexus.Cli/Services/IGatewayServiceInstaller.cs new file mode 100644 index 00000000..dc36fe9e --- /dev/null +++ b/src/gateway/BotNexus.Cli/Services/IGatewayServiceInstaller.cs @@ -0,0 +1,39 @@ +namespace BotNexus.Cli.Services; + +/// +/// Result of a service install or uninstall operation. +/// +public sealed record ServiceInstallResult(bool Success, string Message); + +/// +/// Represents the installation state of the gateway OS service. +/// +public sealed record ServiceInstallStatus(bool IsInstalled, string Platform, string? ServiceName); + +/// +/// Abstracts OS-level service registration: install, uninstall, and status. +/// +public interface IGatewayServiceInstaller +{ + /// + /// Returns the current installation status of the gateway OS service. + /// + Task GetStatusAsync(CancellationToken cancellationToken = default); + + /// + /// Installs and enables the gateway as an OS service. + /// + /// Absolute path to the gateway DLL (dotnet publish output). + /// BotNexus home directory passed to the service via environment variable. + /// HTTP port the gateway should listen on. + Task InstallAsync( + string executablePath, + string homePath, + int port = 5005, + CancellationToken cancellationToken = default); + + /// + /// Stops and removes the gateway OS service registration. + /// + Task UninstallAsync(CancellationToken cancellationToken = default); +} diff --git a/src/gateway/BotNexus.Gateway.Api/BotNexus.Gateway.Api.csproj b/src/gateway/BotNexus.Gateway.Api/BotNexus.Gateway.Api.csproj index f3422ca2..e9d88cdc 100644 --- a/src/gateway/BotNexus.Gateway.Api/BotNexus.Gateway.Api.csproj +++ b/src/gateway/BotNexus.Gateway.Api/BotNexus.Gateway.Api.csproj @@ -20,6 +20,8 @@ + + diff --git a/src/gateway/BotNexus.Gateway.Api/Program.cs b/src/gateway/BotNexus.Gateway.Api/Program.cs index 5b65bfb4..7f9ea780 100644 --- a/src/gateway/BotNexus.Gateway.Api/Program.cs +++ b/src/gateway/BotNexus.Gateway.Api/Program.cs @@ -36,6 +36,10 @@ Args = args }); +// Enable OS service hosting (no-op when running in foreground/CLI mode) +builder.Host.UseSystemd(); +builder.Host.UseWindowsService(); + builder.Host.UseSerilog((context, services, configuration) => configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) @@ -476,4 +480,4 @@ public partial class Program; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => base.SendAsync(request, cancellationToken); -} \ No newline at end of file +} diff --git a/tests/gateway/BotNexus.Cli.Tests/Services/GatewayServiceInstallerTests.cs b/tests/gateway/BotNexus.Cli.Tests/Services/GatewayServiceInstallerTests.cs new file mode 100644 index 00000000..2b3b627f --- /dev/null +++ b/tests/gateway/BotNexus.Cli.Tests/Services/GatewayServiceInstallerTests.cs @@ -0,0 +1,170 @@ +using BotNexus.Cli.Services; +using Shouldly; + +namespace BotNexus.Cli.Tests.Services; + +/// +/// Unit tests for GatewayServiceInstaller content-generation methods (platform-independent). +/// These tests validate the generated service unit file / plist content without calling +/// the OS service manager or writing to privileged paths. +/// +public sealed class GatewayServiceInstallerTests +{ + // ── BuildSystemdUnit ──────────────────────────────────────────────────── + + [Fact] + public void BuildSystemdUnit_ContainsExecStartWithExecutable() + { + var unit = GatewayServiceInstaller.BuildSystemdUnit( + executablePath: "/opt/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/home/user/.botnexus", + port: 5005); + + unit.ShouldContain("/opt/botnexus/BotNexus.Gateway.Api.dll"); + unit.ShouldContain("--urls \"http://localhost:5005\""); + } + + [Fact] + public void BuildSystemdUnit_ContainsHomePath() + { + var unit = GatewayServiceInstaller.BuildSystemdUnit( + executablePath: "/opt/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/home/alice/.botnexus", + port: 5005); + + unit.ShouldContain("BOTNEXUS_HOME=/home/alice/.botnexus"); + } + + [Fact] + public void BuildSystemdUnit_UsesCustomPort() + { + var unit = GatewayServiceInstaller.BuildSystemdUnit( + executablePath: "/opt/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/home/user/.botnexus", + port: 8080); + + unit.ShouldContain("http://localhost:8080"); + unit.ShouldNotContain("http://localhost:5005"); + } + + [Fact] + public void BuildSystemdUnit_HasCorrectSections() + { + var unit = GatewayServiceInstaller.BuildSystemdUnit( + executablePath: "/opt/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/home/user/.botnexus", + port: 5005); + + unit.ShouldContain("[Unit]"); + unit.ShouldContain("[Service]"); + unit.ShouldContain("[Install]"); + unit.ShouldContain("WantedBy=multi-user.target"); + unit.ShouldContain("Restart=on-failure"); + } + + // ── BuildLaunchAgentPlist ─────────────────────────────────────────────── + + [Fact] + public void BuildLaunchAgentPlist_ContainsExecutableAndPort() + { + var plist = GatewayServiceInstaller.BuildLaunchAgentPlist( + executablePath: "/Users/alice/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/Users/alice/.botnexus", + port: 5005); + + plist.ShouldContain("/Users/alice/botnexus/BotNexus.Gateway.Api.dll"); + plist.ShouldContain("http://localhost:5005"); + } + + [Fact] + public void BuildLaunchAgentPlist_ContainsHomePath() + { + var plist = GatewayServiceInstaller.BuildLaunchAgentPlist( + executablePath: "/Users/alice/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/Users/alice/.botnexus", + port: 5005); + + plist.ShouldContain("BOTNEXUS_HOME"); + plist.ShouldContain("/Users/alice/.botnexus"); + } + + [Fact] + public void BuildLaunchAgentPlist_HasLabelAndRunAtLoad() + { + var plist = GatewayServiceInstaller.BuildLaunchAgentPlist( + executablePath: "/usr/local/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/Users/user/.botnexus", + port: 5005); + + plist.ShouldContain(GatewayServiceInstaller.LaunchAgentPlistName); + plist.ShouldContain("RunAtLoad"); + plist.ShouldContain(""); + plist.ShouldContain("KeepAlive"); + } + + [Fact] + public void BuildLaunchAgentPlist_IsValidXml() + { + var plist = GatewayServiceInstaller.BuildLaunchAgentPlist( + executablePath: "/opt/botnexus/BotNexus.Gateway.Api.dll", + homePath: "/Users/user/.botnexus", + port: 5005); + + // Validate it's at least parseable XML + var exception = Record.Exception(() => + { + var doc = new System.Xml.XmlDocument(); + doc.LoadXml(plist); + }); + exception.ShouldBeNull(); + } + + // ── InstallAsync argument validation ──────────────────────────────────── + + [Fact] + public async Task InstallAsync_EmptyExecutablePath_ReturnsFail() + { + var installer = new GatewayServiceInstaller(); + var result = await installer.InstallAsync( + executablePath: "", + homePath: "/home/.botnexus"); + + result.Success.ShouldBeFalse(); + result.Message.ShouldContain("required"); + } + + [Fact] + public async Task InstallAsync_MissingExecutable_ReturnsFail() + { + var installer = new GatewayServiceInstaller(); + var result = await installer.InstallAsync( + executablePath: "/nonexistent/path/BotNexus.Gateway.Api.dll", + homePath: "/home/.botnexus"); + + result.Success.ShouldBeFalse(); + result.Message.ShouldContain("not found"); + } + + // ── ServiceInstallResult / ServiceInstallStatus record tests ──────────── + + [Theory] + [InlineData(true, "Test service installed.")] + [InlineData(false, "Install failed: permission denied.")] + public void ServiceInstallResult_RecordEquality(bool success, string message) + { + var a = new ServiceInstallResult(success, message); + var b = new ServiceInstallResult(success, message); + a.ShouldBe(b); + a.Success.ShouldBe(success); + a.Message.ShouldBe(message); + } + + [Fact] + public void ServiceInstallStatus_Installed_ReflectsState() + { + var status = new ServiceInstallStatus(IsInstalled: true, Platform: "linux", ServiceName: "botnexus"); + status.IsInstalled.ShouldBeTrue(); + status.Platform.ShouldBe("linux"); + status.ServiceName.ShouldBe("botnexus"); + } +}