From eda5ae96170966dca772d9403c191da5f26309c9 Mon Sep 17 00:00:00 2001 From: parasmani-dev Date: Sun, 17 May 2026 16:47:37 +0530 Subject: [PATCH 1/2] feat(plugins): add native support for .ps1 PowerShell plugins --- plugins/test-powershell/plugin.ps1 | 87 ++++++++++++++++++++++++ plugins/test-powershell/plugin.yaml | 7 ++ src/Services/Plugins/PluginManager.cs | 4 ++ src/Services/Plugins/PluginRunner.cs | 22 +++++- tests/WinHome.Tests/PluginSystemTests.cs | 85 +++++++++++++++++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 plugins/test-powershell/plugin.ps1 create mode 100644 plugins/test-powershell/plugin.yaml diff --git a/plugins/test-powershell/plugin.ps1 b/plugins/test-powershell/plugin.ps1 new file mode 100644 index 0000000..0489a77 --- /dev/null +++ b/plugins/test-powershell/plugin.ps1 @@ -0,0 +1,87 @@ +# WinHome PowerShell Plugin Implementation +# Implements the process-based JSON-over-Stdin/Stdout IPC protocol. + +# 1. Read input from standard input (stdin) +$jsonInput = [System.Console]::In.ReadToEnd() +if ([string]::IsNullOrWhiteSpace($jsonInput)) { + $jsonInput = $Input | Out-String +} + +# 2. Parse request JSON +try { + $request = ConvertFrom-Json $jsonInput +} catch { + [System.Console]::Error.WriteLine("[test-powershell] Error: Failed to parse request JSON: $_") + exit 1 +} + +$command = $request.command +$requestId = $request.requestId +$args = $request.args +$context = $request.context + +# Log info to stderr (Host will capture and pipe to WinHome main logs) +[System.Console]::Error.WriteLine("[test-powershell] Received command '$command' with requestId '$requestId'") + +# 3. Setup Response Structure +$success = $true +$changed = $false +$error = $null +$data = $null + +# 4. Command Routing Logic +switch ($command) { + "check_installed" { + $packageId = $args.packageId + [System.Console]::Error.WriteLine("[test-powershell] Checking if $packageId is installed...") + # Simulated check: suppose 'demo-pkg' is installed, others not. + if ($packageId -eq "demo-pkg") { + $data = $true + } else { + $data = $false + } + } + + "install" { + $packageId = $args.packageId + [System.Console]::Error.WriteLine("[test-powershell] Installing $packageId...") + $changed = $true + $data = "Installed package $packageId successfully." + } + + "uninstall" { + $packageId = $args.packageId + [System.Console]::Error.WriteLine("[test-powershell] Uninstalling $packageId...") + $changed = $true + $data = "Uninstalled package $packageId successfully." + } + + "apply" { + [System.Console]::Error.WriteLine("[test-powershell] Applying configuration...") + if ($null -ne $args) { + foreach ($key in $args.psobject.properties.name) { + $val = $args.$key + [System.Console]::Error.WriteLine("[test-powershell] Setting $key = $val") + } + } + $changed = $true + $data = "Configuration applied successfully." + } + + default { + $success = $false + $error = "Unknown command: $command" + } +} + +# 5. Output Response JSON to stdout +$response = @{ + requestId = $requestId + success = $success + changed = $changed + error = $error + data = $data +} + +$jsonResponse = $response | ConvertTo-Json -Compress +Write-Output $jsonResponse diff --git a/plugins/test-powershell/plugin.yaml b/plugins/test-powershell/plugin.yaml new file mode 100644 index 0000000..34ef9d6 --- /dev/null +++ b/plugins/test-powershell/plugin.yaml @@ -0,0 +1,7 @@ +name: test-powershell +version: 0.1.0 +type: powershell +main: plugin.ps1 +capabilities: + - package_manager + - config_provider diff --git a/src/Services/Plugins/PluginManager.cs b/src/Services/Plugins/PluginManager.cs index 7816f8b..d459d6a 100644 --- a/src/Services/Plugins/PluginManager.cs +++ b/src/Services/Plugins/PluginManager.cs @@ -85,6 +85,10 @@ public async Task EnsureRuntimeAsync(PluginManifest plugin) await Task.Run(() => _bunBootstrapper.Install(false)); } break; + + case "powershell": + _logger.LogInfo($"[Plugin] {plugin.Name} requires 'powershell'. Assuming system powershell is available."); + break; } } } diff --git a/src/Services/Plugins/PluginRunner.cs b/src/Services/Plugins/PluginRunner.cs index f545e59..997b2f1 100644 --- a/src/Services/Plugins/PluginRunner.cs +++ b/src/Services/Plugins/PluginRunner.cs @@ -127,7 +127,7 @@ public async Task ExecuteAsync(PluginManifest plugin, string comma } } - private (string FileName, string Arguments) BuildProcessStartInfo(PluginManifest plugin) + public (string FileName, string Arguments) BuildProcessStartInfo(PluginManifest plugin) { string mainPath = Path.Combine(plugin.DirectoryPath, plugin.Main); @@ -147,6 +147,26 @@ public async Task ExecuteAsync(PluginManifest plugin, string comma case "executable": return (mainPath, ""); + case "powershell": + string powershellPath = "powershell"; + try + { + var pwshResolved = _runtimeResolver.Resolve("pwsh"); + if (pwshResolved != "pwsh" && File.Exists(pwshResolved)) + { + powershellPath = pwshResolved; + } + else + { + powershellPath = _runtimeResolver.Resolve("powershell"); + } + } + catch + { + powershellPath = _runtimeResolver.Resolve("powershell"); + } + return (powershellPath, $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{mainPath}\""); + default: throw new NotSupportedException($"Plugin type '{plugin.Type}' is not supported."); } diff --git a/tests/WinHome.Tests/PluginSystemTests.cs b/tests/WinHome.Tests/PluginSystemTests.cs index 6d7a7e4..f41709c 100644 --- a/tests/WinHome.Tests/PluginSystemTests.cs +++ b/tests/WinHome.Tests/PluginSystemTests.cs @@ -137,5 +137,90 @@ public void Adapter_Translates_Install_Call_To_Runner() It.IsAny()), Times.Once); } + + [Fact] + public void PluginRunner_Builds_Correct_PowerShell_Command_Fallback() + { + // Arrange + var mockLogger = new Mock(); + var mockRuntimeResolver = new Mock(); + + mockRuntimeResolver.Setup(r => r.Resolve("pwsh")).Returns("pwsh"); + mockRuntimeResolver.Setup(r => r.Resolve("powershell")).Returns(@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"); + + var runner = new PluginRunner(mockLogger.Object, mockRuntimeResolver.Object); + var manifest = new PluginManifest + { + Name = "test-powershell-plugin", + Type = "powershell", + Main = "plugin.ps1", + DirectoryPath = @"C:\plugins\test-powershell-plugin" + }; + + // Act + var (fileName, arguments) = runner.BuildProcessStartInfo(manifest); + + // Assert + Assert.Equal(@"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", fileName); + Assert.Contains("-NoProfile", arguments); + Assert.Contains("-NonInteractive", arguments); + Assert.Contains("-ExecutionPolicy Bypass", arguments); + Assert.Contains("-File", arguments); + Assert.Contains("plugin.ps1", arguments); + } + + [Fact] + public void PluginRunner_Builds_Correct_PowerShell_Command_Core() + { + // Arrange + var mockLogger = new Mock(); + var mockRuntimeResolver = new Mock(); + + // We mock pwsh to point to powershell.exe since we know it exists. + var existingPath = @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + mockRuntimeResolver.Setup(r => r.Resolve("pwsh")).Returns(existingPath); + + var runner = new PluginRunner(mockLogger.Object, mockRuntimeResolver.Object); + var manifest = new PluginManifest + { + Name = "test-powershell-plugin", + Type = "powershell", + Main = "plugin.ps1", + DirectoryPath = @"C:\plugins\test-powershell-plugin" + }; + + // Act + var (fileName, arguments) = runner.BuildProcessStartInfo(manifest); + + // Assert + Assert.Equal(existingPath, fileName); + Assert.Contains("-NoProfile", arguments); + Assert.Contains("-NonInteractive", arguments); + Assert.Contains("-ExecutionPolicy Bypass", arguments); + Assert.Contains("-File", arguments); + Assert.Contains("plugin.ps1", arguments); + } + + [Fact] + public async Task PluginManager_EnsureRuntime_Supports_PowerShell() + { + // Arrange + var mockLogger = new Mock(); + var mockProcessRunner = new Mock(); + var uvBootstrapper = new WinHome.Services.Bootstrappers.UvBootstrapper(mockProcessRunner.Object); + var bunBootstrapper = new WinHome.Services.Bootstrappers.BunBootstrapper(mockProcessRunner.Object); + + var manager = new PluginManager(uvBootstrapper, bunBootstrapper, mockLogger.Object, Path.GetTempPath()); + var manifest = new PluginManifest + { + Name = "test-powershell-plugin", + Type = "powershell" + }; + + // Act & Assert + var exception = await Record.ExceptionAsync(() => manager.EnsureRuntimeAsync(manifest)); + Assert.Null(exception); + mockLogger.Verify(l => l.LogInfo(It.Is(s => s.Contains("requires 'powershell'") && s.Contains("Assuming system powershell"))), Times.Once); + } } } From e3894e0ddcf5d5370b9888b8f188cc64c53d244d Mon Sep 17 00:00:00 2001 From: parasmani-dev Date: Mon, 18 May 2026 16:05:22 +0530 Subject: [PATCH 2/2] feat(plugin): implement granular powershell logging and fix automatic variable collision in plugin.ps1 --- plugins/test-powershell/plugin.ps1 | 6 +-- src/Infrastructure/AppHost.cs | 3 +- src/Services/Plugins/PluginManager.cs | 27 +++++++++++- tests/WinHome.Tests/PluginSystemTests.cs | 55 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/plugins/test-powershell/plugin.ps1 b/plugins/test-powershell/plugin.ps1 index 0489a77..f663e69 100644 --- a/plugins/test-powershell/plugin.ps1 +++ b/plugins/test-powershell/plugin.ps1 @@ -26,7 +26,7 @@ $context = $request.context # 3. Setup Response Structure $success = $true $changed = $false -$error = $null +$err = $null $data = $null # 4. Command Routing Logic @@ -70,7 +70,7 @@ switch ($command) { default { $success = $false - $error = "Unknown command: $command" + $err = "Unknown command: $command" } } @@ -79,7 +79,7 @@ $response = @{ requestId = $requestId success = $success changed = $changed - error = $error + error = $err data = $data } diff --git a/src/Infrastructure/AppHost.cs b/src/Infrastructure/AppHost.cs index 14291dd..bd35799 100644 --- a/src/Infrastructure/AppHost.cs +++ b/src/Infrastructure/AppHost.cs @@ -62,7 +62,8 @@ public static void ConfigureServices(IConfiguration configuration, IServiceColle sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - null + null, + sp.GetRequiredService() )); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Services/Plugins/PluginManager.cs b/src/Services/Plugins/PluginManager.cs index d459d6a..fddc088 100644 --- a/src/Services/Plugins/PluginManager.cs +++ b/src/Services/Plugins/PluginManager.cs @@ -13,16 +13,19 @@ public class PluginManager : IPluginManager private readonly BunBootstrapper _bunBootstrapper; private readonly ILogger _logger; private readonly string _pluginsDir; + private readonly IRuntimeResolver? _runtimeResolver; public PluginManager( UvBootstrapper uvBootstrapper, BunBootstrapper bunBootstrapper, ILogger logger, - string? pluginsDirectory = null) + string? pluginsDirectory = null, + IRuntimeResolver? runtimeResolver = null) { _uvBootstrapper = uvBootstrapper; _bunBootstrapper = bunBootstrapper; _logger = logger; + _runtimeResolver = runtimeResolver; _pluginsDir = pluginsDirectory ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -87,7 +90,27 @@ public async Task EnsureRuntimeAsync(PluginManifest plugin) break; case "powershell": - _logger.LogInfo($"[Plugin] {plugin.Name} requires 'powershell'. Assuming system powershell is available."); + string resolvedMessage = "Assuming system powershell is available."; + if (_runtimeResolver != null) + { + try + { + var pwshResolved = _runtimeResolver.Resolve("pwsh"); + if (pwshResolved != "pwsh" && File.Exists(pwshResolved)) + { + resolvedMessage = "Using pwsh (Core)."; + } + else + { + resolvedMessage = "Falling back to Windows PowerShell."; + } + } + catch + { + resolvedMessage = "Falling back to Windows PowerShell."; + } + } + _logger.LogInfo($"[Plugin] {plugin.Name} requires 'powershell'. {resolvedMessage}"); break; } } diff --git a/tests/WinHome.Tests/PluginSystemTests.cs b/tests/WinHome.Tests/PluginSystemTests.cs index f41709c..2d9714a 100644 --- a/tests/WinHome.Tests/PluginSystemTests.cs +++ b/tests/WinHome.Tests/PluginSystemTests.cs @@ -222,5 +222,60 @@ public async Task PluginManager_EnsureRuntime_Supports_PowerShell() Assert.Null(exception); mockLogger.Verify(l => l.LogInfo(It.Is(s => s.Contains("requires 'powershell'") && s.Contains("Assuming system powershell"))), Times.Once); } + + [Fact] + public async Task PluginManager_EnsureRuntime_Logs_PwshCore_WhenResolved() + { + // Arrange + var mockLogger = new Mock(); + var mockProcessRunner = new Mock(); + var mockRuntimeResolver = new Mock(); + var uvBootstrapper = new WinHome.Services.Bootstrappers.UvBootstrapper(mockProcessRunner.Object); + var bunBootstrapper = new WinHome.Services.Bootstrappers.BunBootstrapper(mockProcessRunner.Object); + + // Mock pwsh to exist and return a valid path (using powershell.exe since we know it exists on Windows) + var existingPath = @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + mockRuntimeResolver.Setup(r => r.Resolve("pwsh")).Returns(existingPath); + + var manager = new PluginManager(uvBootstrapper, bunBootstrapper, mockLogger.Object, Path.GetTempPath(), mockRuntimeResolver.Object); + var manifest = new PluginManifest + { + Name = "test-powershell-plugin", + Type = "powershell" + }; + + // Act + await manager.EnsureRuntimeAsync(manifest); + + // Assert + mockLogger.Verify(l => l.LogInfo(It.Is(s => s.Contains("requires 'powershell'") && s.Contains("Using pwsh (Core)"))), Times.Once); + } + + [Fact] + public async Task PluginManager_EnsureRuntime_Logs_WindowsPowerShell_Fallback_WhenPwshNotResolved() + { + // Arrange + var mockLogger = new Mock(); + var mockProcessRunner = new Mock(); + var mockRuntimeResolver = new Mock(); + var uvBootstrapper = new WinHome.Services.Bootstrappers.UvBootstrapper(mockProcessRunner.Object); + var bunBootstrapper = new WinHome.Services.Bootstrappers.BunBootstrapper(mockProcessRunner.Object); + + // Mock pwsh to return "pwsh" (meaning it failed to resolve to a concrete file path) + mockRuntimeResolver.Setup(r => r.Resolve("pwsh")).Returns("pwsh"); + + var manager = new PluginManager(uvBootstrapper, bunBootstrapper, mockLogger.Object, Path.GetTempPath(), mockRuntimeResolver.Object); + var manifest = new PluginManifest + { + Name = "test-powershell-plugin", + Type = "powershell" + }; + + // Act + await manager.EnsureRuntimeAsync(manifest); + + // Assert + mockLogger.Verify(l => l.LogInfo(It.Is(s => s.Contains("requires 'powershell'") && s.Contains("Falling back to Windows PowerShell"))), Times.Once); + } } }