Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions plugins/test-powershell/plugin.ps1
Original file line number Diff line number Diff line change
@@ -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
$err = $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
$err = "Unknown command: $command"
}
}

# 5. Output Response JSON to stdout
$response = @{
requestId = $requestId
success = $success
changed = $changed
error = $err
data = $data
}

$jsonResponse = $response | ConvertTo-Json -Compress
Write-Output $jsonResponse
7 changes: 7 additions & 0 deletions plugins/test-powershell/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: test-powershell
version: 0.1.0
type: powershell
main: plugin.ps1
capabilities:
- package_manager
- config_provider
3 changes: 2 additions & 1 deletion src/Infrastructure/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ public static void ConfigureServices(IConfiguration configuration, IServiceColle
sp.GetRequiredService<UvBootstrapper>(),
sp.GetRequiredService<BunBootstrapper>(),
sp.GetRequiredService<ILogger>(),
null
null,
sp.GetRequiredService<IRuntimeResolver>()
));
services.AddSingleton<IGeneratorService, GeneratorService>();
services.AddSingleton<IPluginRunner, PluginRunner>();
Expand Down
29 changes: 28 additions & 1 deletion src/Services/Plugins/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -85,6 +88,30 @@ public async Task EnsureRuntimeAsync(PluginManifest plugin)
await Task.Run(() => _bunBootstrapper.Install(false));
}
break;

case "powershell":
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;
}
}
}
Expand Down
22 changes: 21 additions & 1 deletion src/Services/Plugins/PluginRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public async Task<PluginResult> 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);

Expand All @@ -147,6 +147,26 @@ public async Task<PluginResult> 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.");
}
Expand Down
140 changes: 140 additions & 0 deletions tests/WinHome.Tests/PluginSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,145 @@ public void Adapter_Translates_Install_Call_To_Runner()
It.IsAny<object>()),
Times.Once);
}

[Fact]
public void PluginRunner_Builds_Correct_PowerShell_Command_Fallback()
{
// Arrange
var mockLogger = new Mock<ILogger>();
var mockRuntimeResolver = new Mock<IRuntimeResolver>();

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<ILogger>();
var mockRuntimeResolver = new Mock<IRuntimeResolver>();

// 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<ILogger>();
var mockProcessRunner = new Mock<IProcessRunner>();
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<string>(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<ILogger>();
var mockProcessRunner = new Mock<IProcessRunner>();
var mockRuntimeResolver = new Mock<IRuntimeResolver>();
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<string>(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<ILogger>();
var mockProcessRunner = new Mock<IProcessRunner>();
var mockRuntimeResolver = new Mock<IRuntimeResolver>();
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<string>(s => s.Contains("requires 'powershell'") && s.Contains("Falling back to Windows PowerShell"))), Times.Once);
}
}
}
Loading