From 3c7a09a8a39ca3a55aa80f7707ee3cbaf6c82805 Mon Sep 17 00:00:00 2001 From: Matthew Makary Date: Thu, 27 Mar 2025 01:16:20 -0400 Subject: [PATCH] Feat: Added a launch command from the CLI --- .../godot/commands/GodotLaunchCommandTest.cs | 106 ++++++++++++++++++ .../src/common/utilities/ProcessRunner.cs | 31 ++++- .../godot/commands/GodotLaunchCommand.cs | 47 ++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 GodotEnv.Tests/src/features/godot/commands/GodotLaunchCommandTest.cs create mode 100644 GodotEnv/src/features/godot/commands/GodotLaunchCommand.cs diff --git a/GodotEnv.Tests/src/features/godot/commands/GodotLaunchCommandTest.cs b/GodotEnv.Tests/src/features/godot/commands/GodotLaunchCommandTest.cs new file mode 100644 index 00000000..e9a11432 --- /dev/null +++ b/GodotEnv.Tests/src/features/godot/commands/GodotLaunchCommandTest.cs @@ -0,0 +1,106 @@ +namespace Chickensoft.GodotEnv.Tests.features.godot.commands; + +using System; +using System.IO; +using System.Threading.Tasks; +using Chickensoft.GodotEnv.Common.Clients; +using Chickensoft.GodotEnv.Features.Godot.Commands; +using Chickensoft.GodotEnv.Features.Godot.Domain; +using Chickensoft.GodotEnv.Features.Godot.Models; +using CliFx.Infrastructure; +using Common.Models; +using Common.Utilities; +using global::GodotEnv.Common.Utilities; +using Moq; +using Shouldly; +using Xunit; + +public sealed class GodotLaunchCommandTest : IDisposable { + private readonly MockSystemInfo _systemInfo; + private readonly Mock _context; + private readonly Mock _godotContext; + private readonly Mock _environment; + private readonly Mock _godotRepo; + private readonly Mock _processRunner; + private readonly FakeInMemoryConsole _console; + private readonly Mock _fileClient; + private readonly Log _log; + + public GodotLaunchCommandTest() { + _systemInfo = new MockSystemInfo(OSType.Linux, CPUArch.X64); + _context = new Mock(); + _godotContext = new Mock(); + _environment = new Mock(); + _godotRepo = new Mock(); + _processRunner = new Mock(); + _console = new FakeInMemoryConsole(); + _fileClient = new Mock(); + + + _environment.Setup(env => env.SystemInfo).Returns(_systemInfo); + _godotContext.SetupGet(c => c.GodotRepo).Returns(_godotRepo.Object); + _godotContext.Setup(c => c.Platform).Returns(_environment.Object); + _context.SetupGet(context => context.Godot).Returns(_godotContext.Object); + _log = new Log(_systemInfo, _console); + _context.Setup(context => context.CreateLog(_console)).Returns(_log); + + _godotRepo.SetupGet(r => r.ProcessRunner).Returns(_processRunner.Object); + } + + public void Dispose() { + _console.Dispose(); + Environment.SetEnvironmentVariable("GODOT", null); + } + + [Fact] + public async Task Launches_Godot_When_Env_Variable_Is_Valid() { + var godotPath = Path.Combine(Path.GetTempPath(), "godot-test-launcher"); + + // Create a fake Godot binary + File.WriteAllText(godotPath, string.Empty); + Environment.SetEnvironmentVariable("GODOT", godotPath); + + var launchCommand = new GodotLaunchCommand(_context.Object); + + await launchCommand.ExecuteAsync(_console); + + _processRunner.Verify(p => + p.RunDetached(godotPath, Array.Empty()), Times.Once + ); + + _log.ToString().ShouldContain($"Launching Godot from {godotPath}"); + + // Clean up + File.Delete(godotPath); + } + + [Fact] + public async Task Fails_When_GODOT_Variable_Is_Unset() { + Environment.SetEnvironmentVariable("GODOT", null); + + var launchCommand = new GodotLaunchCommand(_context.Object); + await launchCommand.ExecuteAsync(_console); + + _processRunner.Verify(p => + p.RunDetached(It.IsAny(), It.IsAny()), Times.Never + ); + + _log.ToString().ShouldContain("The GODOT environment variable is not set"); + } + + [Fact] + public async Task Fails_When_GODOT_Target_Does_Not_Exist() { + var godotPath = "/nonexistent/godot"; + Environment.SetEnvironmentVariable("GODOT", godotPath); + + var launchCommand = new GodotLaunchCommand(_context.Object); + await launchCommand.ExecuteAsync(_console); + + _processRunner.Verify(p => + p.RunDetached(It.IsAny(), It.IsAny()), Times.Never + ); + + _log.ToString().ShouldContain($"The GODOT environment variable points to a missing file: {godotPath}"); + } +} + diff --git a/GodotEnv/src/common/utilities/ProcessRunner.cs b/GodotEnv/src/common/utilities/ProcessRunner.cs index 2bd92893..ffa55bb9 100644 --- a/GodotEnv/src/common/utilities/ProcessRunner.cs +++ b/GodotEnv/src/common/utilities/ProcessRunner.cs @@ -35,7 +35,16 @@ public interface IProcessRunner { /// /// Process arguments. /// Process result task. - Task Run(string workingDir, string exe, string[] args); + Task Run(string workingDir, string exe, string[] args); + + /// + /// Starts a detached process. (e.g. launching a GUI app) without waiting for it. + /// + /// Process arguments. + /// + /// Process to run (must be in the system shell's path). + /// + Task RunDetached(string exe, string[] args); /// /// Runs an external process with callbacks for stdout and stderr. @@ -85,6 +94,26 @@ public async Task Run( StandardOutput: stdOutBuffer.ToString(), StandardError: stdErrBuffer.ToString() ); + } + + // This method is used to run a detached process. + public Task RunDetached(string exe, string[] args) { + var startInfo = new ProcessStartInfo { + FileName = exe, + Arguments = string.Join(" ", args), + UseShellExecute = true, + CreateNoWindow = true, + WorkingDirectory = Environment.CurrentDirectory + }; + + try { + Process.Start(startInfo); + } + catch (Exception ex) { + Console.Error.WriteLine($"Failed to launch detached process: {ex.Message}"); + } + + return Task.CompletedTask; } public async Task RunElevatedOnWindows( diff --git a/GodotEnv/src/features/godot/commands/GodotLaunchCommand.cs b/GodotEnv/src/features/godot/commands/GodotLaunchCommand.cs new file mode 100644 index 00000000..fa8239e1 --- /dev/null +++ b/GodotEnv/src/features/godot/commands/GodotLaunchCommand.cs @@ -0,0 +1,47 @@ +namespace Chickensoft.GodotEnv.Features.Godot.Commands; + +using System; +using System.IO; +using System.Threading.Tasks; +using Chickensoft.GodotEnv.Common.Clients; +using Chickensoft.GodotEnv.Common.Models; +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; +using global::GodotEnv.Common.Utilities; + +[Command("godot launch", Description = "Launches the currently active Godot version.")] +public class GodotLaunchCommand : ICommand, ICliCommand { + public IExecutionContext ExecutionContext { get; set; } = default!; + + public GodotLaunchCommand(IExecutionContext context) { + ExecutionContext = context; + } + + private ISystemInfo SystemInfo => ExecutionContext.Godot.Platform.SystemInfo; + + + public async ValueTask ExecuteAsync(IConsole console) { + var log = ExecutionContext.CreateLog(console); + + string? godotPath = Environment.GetEnvironmentVariable("GODOT"); + + if (string.IsNullOrWhiteSpace(godotPath)) { + log.Err("❌ The GODOT environment variable is not set."); + log.Print("To set it, use:\n godotenv godot use \n"); + return; + } + + if (SystemInfo.OS == OSType.Windows && !godotPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) { + godotPath += ".exe"; + } + + if (!File.Exists(godotPath)) { + log.Err($"❌ The GODOT environment variable points to a missing file: {godotPath}"); + return; + } + + log.Print($"🚀 Launching Godot from {godotPath}..."); + await ExecutionContext.Godot.GodotRepo.ProcessRunner.RunDetached(godotPath, []); + } +}