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");
+ }
+}