From f87756243e52da7a64bcfd15c6c0deb25af74d99 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Sun, 28 Jun 2026 23:02:35 -0500 Subject: [PATCH] Add support for defining PowerShell modules Adds a new section "Scripting" in the server UI where PowerShell modules can be defined. These can be used for defining a library of functions that can be used in any script. These modules are automatically synced to the launcher and imported upon script execution. --- .../ViewModels/LibraryViewModel.cs | 8 +- LANCommander.SDK/Clients/ModuleClient.cs | 69 +++ .../IServiceCollectionExtensions.cs | 1 + .../Models/Settings/ModuleSettings.cs | 6 + LANCommander.SDK/Models/Settings/Settings.cs | 1 + .../PowerShell/PowerShellScript.cs | 10 +- .../IServiceCollectionExtensions.cs | 1 + LANCommander.Server.Services/Models/Module.cs | 28 + .../Models/ModuleManifest.cs | 14 + LANCommander.Server.Services/ModuleService.cs | 478 ++++++++++++++++++ LANCommander.Server.Services/ScriptService.cs | 57 +++ .../Models/ModuleSettings.cs | 8 + .../Models/ScriptSettings.cs | 1 + .../Endpoints/ModulesEndpoints.cs | 32 ++ LANCommander.Server/Startup/Endpoints.cs | 1 + LANCommander.Server/Startup/Filesystem.cs | 1 + .../UI/Components/MainMenu.razor | 10 + .../Components/PowerShellScriptEditor.razor | 91 ++++ .../UI/Components/ScriptEditorDialog.razor | 8 +- .../UI/Pages/Scripting/Modules/Edit.razor | 388 ++++++++++++++ .../UI/Pages/Scripting/Modules/Index.razor | 57 +++ .../UI/Pages/Scripting/Snippets/Edit.razor | 86 ++++ .../UI/Pages/Scripting/Snippets/Index.razor | 58 +++ .../Components/CodeInput/CodeInput.razor | 26 + .../CodeInput/ModuleFunctionCompletion.cs | 3 + .../PowerShellCompletionProvider.ts | 41 ++ LANCommander.UI/_Imports.razor.ts | 2 +- 27 files changed, 1482 insertions(+), 4 deletions(-) create mode 100644 LANCommander.SDK/Clients/ModuleClient.cs create mode 100644 LANCommander.SDK/Models/Settings/ModuleSettings.cs create mode 100644 LANCommander.Server.Services/Models/Module.cs create mode 100644 LANCommander.Server.Services/Models/ModuleManifest.cs create mode 100644 LANCommander.Server.Services/ModuleService.cs create mode 100644 LANCommander.Server.Settings/Models/ModuleSettings.cs create mode 100644 LANCommander.Server/Endpoints/ModulesEndpoints.cs create mode 100644 LANCommander.Server/UI/Components/PowerShellScriptEditor.razor create mode 100644 LANCommander.Server/UI/Pages/Scripting/Modules/Edit.razor create mode 100644 LANCommander.Server/UI/Pages/Scripting/Modules/Index.razor create mode 100644 LANCommander.Server/UI/Pages/Scripting/Snippets/Edit.razor create mode 100644 LANCommander.Server/UI/Pages/Scripting/Snippets/Index.razor create mode 100644 LANCommander.UI/Components/CodeInput/ModuleFunctionCompletion.cs diff --git a/LANCommander.Launcher/ViewModels/LibraryViewModel.cs b/LANCommander.Launcher/ViewModels/LibraryViewModel.cs index d4caac3a..3222a3a8 100644 --- a/LANCommander.Launcher/ViewModels/LibraryViewModel.cs +++ b/LANCommander.Launcher/ViewModels/LibraryViewModel.cs @@ -37,7 +37,7 @@ public partial class LibraryViewModel : GamesCollectionViewModel public override bool IsCollectionFiltered => !string.IsNullOrEmpty(SelectedCollection); public override string FilteredCollectionName => SelectedCollection ?? string.Empty; - public LibraryViewModel(IServiceProvider serviceProvider) + public LibraryViewModel(IServiceProvider serviceProvider) : base(serviceProvider) { _serviceProvider = serviceProvider; _logger = serviceProvider.GetRequiredService>(); @@ -67,6 +67,12 @@ public partial class LibraryViewModel : GamesCollectionViewModel var mediaClient = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); + if (!IsOfflineMode) + { + var moduleClient = scope.ServiceProvider.GetRequiredService(); + await moduleClient.SyncAsync(); + } + var items = await libraryService.GetItemsAsync(); var results = new List(); var gameModels = new List(); diff --git a/LANCommander.SDK/Clients/ModuleClient.cs b/LANCommander.SDK/Clients/ModuleClient.cs new file mode 100644 index 00000000..b82ee850 --- /dev/null +++ b/LANCommander.SDK/Clients/ModuleClient.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using LANCommander.SDK.Abstractions; +using LANCommander.SDK.Factories; +using Microsoft.Extensions.Logging; + +namespace LANCommander.SDK.Services +{ + public class ModuleClient( + ILogger logger, + ApiRequestFactory apiRequestFactory, + ISettingsProvider settingsProvider) + { + public async Task> GetAsync() + { + return await apiRequestFactory + .Create() + .UseAuthenticationToken() + .UseVersioning() + .UseRoute("/api/Modules") + .GetAsync>(); + } + + public string GetLocalPath() + => settingsProvider.CurrentValue.Modules.StoragePath; + + public async Task DownloadAsync(string destination) + { + return await apiRequestFactory + .Create() + .UseAuthenticationToken() + .UseVersioning() + .UseRoute("/api/Modules/Download") + .DownloadAsync(destination); + } + + public async Task SyncAsync() + { + var destination = GetLocalPath(); + var archivePath = Path.Combine(Path.GetTempPath(), $"LANCommander.Modules.{Guid.NewGuid()}.zip"); + + try + { + await DownloadAsync(archivePath); + + if (Directory.Exists(destination)) + Directory.Delete(destination, true); + + Directory.CreateDirectory(destination); + + ZipFile.ExtractToDirectory(archivePath, destination, true); + + logger?.LogInformation("Synced PowerShell modules to {Destination}", destination); + } + catch (Exception ex) + { + logger?.LogError(ex, "Could not sync PowerShell modules"); + } + finally + { + if (File.Exists(archivePath)) + File.Delete(archivePath); + } + } + } +} diff --git a/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs b/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs index e93ee476..3ff0b5bf 100644 --- a/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs +++ b/LANCommander.SDK/Extensions/IServiceCollectionExtensions.cs @@ -47,6 +47,7 @@ public static class IServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LANCommander.SDK/Models/Settings/ModuleSettings.cs b/LANCommander.SDK/Models/Settings/ModuleSettings.cs new file mode 100644 index 00000000..41e2c9f7 --- /dev/null +++ b/LANCommander.SDK/Models/Settings/ModuleSettings.cs @@ -0,0 +1,6 @@ +namespace LANCommander.SDK.Models; + +public class ModuleSettings +{ + public string StoragePath { get; set; } = AppPaths.GetConfigPath("Modules"); +} diff --git a/LANCommander.SDK/Models/Settings/Settings.cs b/LANCommander.SDK/Models/Settings/Settings.cs index 8635174f..0c47609e 100644 --- a/LANCommander.SDK/Models/Settings/Settings.cs +++ b/LANCommander.SDK/Models/Settings/Settings.cs @@ -13,6 +13,7 @@ public class Settings public GameSettings Games { get; set; } = new(); public ToolSettings Tools { get; set; } = new(); public MediaSettings Media { get; set; } = new(); + public ModuleSettings Modules { get; set; } = new(); public DebugSettings Debug { get; set; } = new(); public UpdateSettings Updates { get; set; } = new(); public IPXRelaySettings IPXRelay { get; set; } = new(); diff --git a/LANCommander.SDK/PowerShell/PowerShellScript.cs b/LANCommander.SDK/PowerShell/PowerShellScript.cs index 18aeedc7..8e50ff39 100644 --- a/LANCommander.SDK/PowerShell/PowerShellScript.cs +++ b/LANCommander.SDK/PowerShell/PowerShellScript.cs @@ -175,9 +175,17 @@ namespace LANCommander.SDK.PowerShell T result = default; var initialSessionState = InitialSessionState.CreateDefault(); - + initialSessionState.AddCustomCmdlets(); + // Always import synced PowerShell modules so their functions are available to every script + var modulesPath = AppPaths.GetConfigPath("Modules"); + if (Directory.Exists(modulesPath)) + { + foreach (var moduleDirectory in Directory.GetDirectories(modulesPath)) + initialSessionState.ImportPSModule(new[] { moduleDirectory }); + } + DisableWow64Redirection(); using (Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState)) diff --git a/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs b/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs index d00dec53..e8ae13fd 100644 --- a/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs +++ b/LANCommander.Server.Services/Extensions/IServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static class IServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/LANCommander.Server.Services/Models/Module.cs b/LANCommander.Server.Services/Models/Module.cs new file mode 100644 index 00000000..5f2086d1 --- /dev/null +++ b/LANCommander.Server.Services/Models/Module.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; + +namespace LANCommander.Server.Models +{ + public enum FunctionVisibility + { + Public, + Private + } + + public class ModuleFunction + { + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } + public FunctionVisibility Visibility { get; set; } = FunctionVisibility.Public; + public string Content { get; set; } + } + + public class Module + { + public string Name { get; set; } + public string Manifest { get; set; } + public List Functions { get; set; } = new(); + } + + public record VerbGroup(string Name, IReadOnlyList Verbs); +} diff --git a/LANCommander.Server.Services/Models/ModuleManifest.cs b/LANCommander.Server.Services/Models/ModuleManifest.cs new file mode 100644 index 00000000..df028d6f --- /dev/null +++ b/LANCommander.Server.Services/Models/ModuleManifest.cs @@ -0,0 +1,14 @@ +namespace LANCommander.Server.Models +{ + public class ModuleManifest + { + public string RootModule { get; set; } = string.Empty; + public string ModuleVersion { get; set; } = "1.0.0"; + public string Guid { get; set; } = System.Guid.NewGuid().ToString(); + public string Author { get; set; } = string.Empty; + public string CompanyName { get; set; } = string.Empty; + public string Copyright { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string PowerShellVersion { get; set; } = string.Empty; + } +} diff --git a/LANCommander.Server.Services/ModuleService.cs b/LANCommander.Server.Services/ModuleService.cs new file mode 100644 index 00000000..c796332b --- /dev/null +++ b/LANCommander.Server.Services/ModuleService.cs @@ -0,0 +1,478 @@ +using System.IO.Compression; +using System.Management.Automation.Language; +using System.Text.RegularExpressions; +using LANCommander.SDK; +using LANCommander.Server.Models; + +namespace LANCommander.Server.Services +{ + public sealed class ModuleService(SettingsProvider settingsProvider) + { + private static readonly Regex FunctionNamePattern = + new(@"^[A-Za-z][A-Za-z0-9]*-[A-Za-z][A-Za-z0-9]*$", RegexOptions.Compiled); + + // Standard set of approved PowerShell verbs (Get-Verb), grouped by verb group. + private static readonly IReadOnlyList ApprovedVerbGroups = new[] + { + new VerbGroup("Common", new[] + { + "Add", "Clear", "Close", "Copy", "Enter", "Exit", "Find", "Format", "Get", "Hide", "Join", + "Lock", "Move", "New", "Open", "Optimize", "Pop", "Push", "Redo", "Remove", "Rename", "Reset", + "Resize", "Search", "Select", "Set", "Show", "Skip", "Split", "Step", "Switch", "Undo", "Unlock", "Watch", + }), + new VerbGroup("Communications", new[] + { + "Connect", "Disconnect", "Read", "Receive", "Send", "Write", + }), + new VerbGroup("Data", new[] + { + "Backup", "Checkpoint", "Compare", "Compress", "Convert", "ConvertFrom", "ConvertTo", "Dismount", + "Edit", "Expand", "Export", "Group", "Import", "Initialize", "Limit", "Merge", "Mount", "Out", + "Publish", "Restore", "Save", "Sync", "Unpublish", "Update", + }), + new VerbGroup("Diagnostic", new[] + { + "Debug", "Measure", "Ping", "Repair", "Resolve", "Test", "Trace", + }), + new VerbGroup("Lifecycle", new[] + { + "Approve", "Assert", "Build", "Complete", "Confirm", "Deny", "Deploy", "Disable", "Enable", + "Install", "Invoke", "Register", "Request", "Restart", "Resume", "Start", "Stop", "Submit", + "Suspend", "Uninstall", "Unregister", "Wait", + }), + new VerbGroup("Security", new[] + { + "Block", "Grant", "Protect", "Revoke", "Unblock", "Unprotect", + }), + new VerbGroup("Other", new[] + { + "Use", + }), + }; + + private static readonly HashSet ApprovedVerbs = + new(ApprovedVerbGroups.SelectMany(g => g.Verbs), StringComparer.OrdinalIgnoreCase); + + private string GetStoragePath() + { + var storagePath = settingsProvider.CurrentValue.Server.Scripts.Modules.StoragePath; + + if (string.IsNullOrWhiteSpace(storagePath)) + { + storagePath = AppPaths.GetConfigPath("Modules"); + + settingsProvider.Update(s => + { + s.Server.Scripts.Modules.StoragePath = storagePath; + }); + } + + if (!Directory.Exists(storagePath)) + Directory.CreateDirectory(storagePath); + + return storagePath; + } + + private string GetModuleDirectory(string name) => Path.Combine(GetStoragePath(), name); + + private static string GetManifestPath(string moduleDirectory, string name) => + Path.Combine(moduleDirectory, $"{name}.psd1"); + + private static string GetLoaderPath(string moduleDirectory, string name) => + Path.Combine(moduleDirectory, $"{name}.psm1"); + + private static string GetVisibilityDirectory(string moduleDirectory, FunctionVisibility visibility) => + Path.Combine(moduleDirectory, visibility == FunctionVisibility.Public ? "Public" : "Private"); + + public IEnumerable GetModules() + { + var storagePath = GetStoragePath(); + + return Directory + .GetDirectories(storagePath) + .Select(d => GetModule(Path.GetFileName(d))) + .Where(m => m != null) + .OrderBy(m => m.Name); + } + + public Module GetModule(string name) + { + var moduleDirectory = GetModuleDirectory(name); + + if (!Directory.Exists(moduleDirectory)) + return null; + + var manifestPath = GetManifestPath(moduleDirectory, name); + + var module = new Module + { + Name = name, + Manifest = File.Exists(manifestPath) ? File.ReadAllText(manifestPath) : string.Empty, + Functions = new List(), + }; + + foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private }) + { + var directory = GetVisibilityDirectory(moduleDirectory, visibility); + + if (!Directory.Exists(directory)) + continue; + + foreach (var file in Directory.GetFiles(directory, "*.ps1").OrderBy(f => f)) + { + module.Functions.Add(new ModuleFunction + { + Name = Path.GetFileNameWithoutExtension(file), + Visibility = visibility, + Content = File.ReadAllText(file), + }); + } + } + + return module; + } + + public bool ModuleExists(string name) => Directory.Exists(GetModuleDirectory(name)); + + public Module CreateScaffold(string name) + { + return new Module + { + Name = name, + Manifest = GetDefaultManifest(name), + Functions = new List + { + new() + { + Name = "Get-Example", + Visibility = FunctionVisibility.Public, + Content = GetDefaultFunctionContent("Get-Example"), + }, + }, + }; + } + + public void SaveModule(Module module) + { + var moduleDirectory = GetModuleDirectory(module.Name); + + if (!Directory.Exists(moduleDirectory)) + Directory.CreateDirectory(moduleDirectory); + + File.WriteAllText(GetManifestPath(moduleDirectory, module.Name), module.Manifest ?? string.Empty); + File.WriteAllText(GetLoaderPath(moduleDirectory, module.Name), GetLoaderScript()); + + var desiredFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private }) + { + var directory = GetVisibilityDirectory(moduleDirectory, visibility); + + Directory.CreateDirectory(directory); + + foreach (var function in module.Functions.Where(f => f.Visibility == visibility)) + { + if (string.IsNullOrWhiteSpace(function.Name)) + continue; + + var path = Path.Combine(directory, $"{function.Name}.ps1"); + + File.WriteAllText(path, function.Content ?? string.Empty); + desiredFiles.Add(path); + } + } + + // Prune function files that were removed or moved between Public/Private + foreach (var visibility in new[] { FunctionVisibility.Public, FunctionVisibility.Private }) + { + var directory = GetVisibilityDirectory(moduleDirectory, visibility); + + if (!Directory.Exists(directory)) + continue; + + foreach (var file in Directory.GetFiles(directory, "*.ps1")) + { + if (!desiredFiles.Contains(file)) + File.Delete(file); + } + } + } + + public void RenameModule(string oldName, string newName) + { + if (string.Equals(oldName, newName, StringComparison.Ordinal)) + return; + + var module = GetModule(oldName); + + if (module == null) + return; + + DeleteModule(oldName); + + module.Name = newName; + + SaveModule(module); + } + + public void DeleteModule(string name) + { + var moduleDirectory = GetModuleDirectory(name); + + if (Directory.Exists(moduleDirectory)) + Directory.Delete(moduleDirectory, true); + } + + public string GetModulesArchive() + { + var storagePath = GetStoragePath(); + var archivePath = Path.Combine(Path.GetTempPath(), $"LANCommander.Modules.{Guid.NewGuid()}.zip"); + + ZipFile.CreateFromDirectory(storagePath, archivePath, CompressionLevel.Optimal, false); + + return archivePath; + } + + public ModuleManifest ParseManifest(string manifest) + { + var result = new ModuleManifest(); + + if (string.IsNullOrWhiteSpace(manifest)) + return result; + + var ast = Parser.ParseInput(manifest, out _, out _); + + var hashtable = ast + .Find(a => a is HashtableAst, false) as HashtableAst; + + if (hashtable == null) + return result; + + foreach (var pair in hashtable.KeyValuePairs) + { + if (pair.Item1 is not StringConstantExpressionAst keyAst) + continue; + + var value = GetScalarString(pair.Item2); + + if (value == null) + continue; + + switch (keyAst.Value) + { + case "RootModule": + result.RootModule = value; + break; + case "ModuleVersion": + result.ModuleVersion = value; + break; + case "GUID": + result.Guid = value; + break; + case "Author": + result.Author = value; + break; + case "CompanyName": + result.CompanyName = value; + break; + case "Copyright": + result.Copyright = value; + break; + case "Description": + result.Description = value; + break; + case "PowerShellVersion": + result.PowerShellVersion = value; + break; + } + } + + return result; + } + + public string GenerateManifest(ModuleManifest manifest, IEnumerable functionNames, IEnumerable aliasNames) + { + var functions = functionNames?.Distinct().OrderBy(n => n).ToList() ?? new List(); + var aliases = aliasNames?.Distinct().OrderBy(n => n).ToList() ?? new List(); + + var builder = new System.Text.StringBuilder(); + + builder.Append("@{\n"); + builder.Append($" RootModule = '{Escape(manifest.RootModule)}'\n"); + builder.Append($" ModuleVersion = '{Escape(manifest.ModuleVersion)}'\n"); + builder.Append($" GUID = '{Escape(manifest.Guid)}'\n"); + + if (!string.IsNullOrWhiteSpace(manifest.Author)) + builder.Append($" Author = '{Escape(manifest.Author)}'\n"); + + if (!string.IsNullOrWhiteSpace(manifest.CompanyName)) + builder.Append($" CompanyName = '{Escape(manifest.CompanyName)}'\n"); + + if (!string.IsNullOrWhiteSpace(manifest.Copyright)) + builder.Append($" Copyright = '{Escape(manifest.Copyright)}'\n"); + + if (!string.IsNullOrWhiteSpace(manifest.Description)) + builder.Append($" Description = '{Escape(manifest.Description)}'\n"); + + if (!string.IsNullOrWhiteSpace(manifest.PowerShellVersion)) + builder.Append($" PowerShellVersion = '{Escape(manifest.PowerShellVersion)}'\n"); + + builder.Append($" FunctionsToExport = {FormatArray(functions)}\n"); + builder.Append(" CmdletsToExport = @()\n"); + builder.Append(" VariablesToExport = @()\n"); + builder.Append($" AliasesToExport = {FormatArray(aliases)}\n"); + builder.Append("}\n"); + + return builder.ToString(); + } + + public IReadOnlyList GetExportedFunctions(Module module) + { + if (module?.Functions == null) + return []; + + return module.Functions + .Where(f => f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name)) + .Select(f => f.Name) + .Distinct() + .OrderBy(n => n) + .ToList(); + } + + public IReadOnlyList GetExportedAliases(Module module) + { + if (module?.Functions == null) + return []; + + var aliases = new List(); + + foreach (var function in module.Functions) + aliases.AddRange(GetAliasesFromScript(function.Content)); + + return aliases.Distinct().OrderBy(a => a).ToList(); + } + + public IEnumerable<(string Name, string Synopsis, string Module)> GetPublicFunctionCompletions() + { + foreach (var module in GetModules()) + { + foreach (var function in module.Functions.Where(f => + f.Visibility == FunctionVisibility.Public && !string.IsNullOrWhiteSpace(f.Name))) + { + yield return (function.Name, ExtractSynopsis(function.Content), module.Name); + } + } + } + + private static string ExtractSynopsis(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return null; + + var match = Regex.Match(content, @"\.SYNOPSIS\s*\r?\n\s*(?.+)"); + + return match.Success ? match.Groups["synopsis"].Value.Trim() : null; + } + + public IReadOnlyList GetApprovedVerbGroups() => + ApprovedVerbGroups + .Select(g => new VerbGroup(g.Name, g.Verbs.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToArray())) + .ToList(); + + public bool IsValidFunctionName(string name) => + !string.IsNullOrWhiteSpace(name) && FunctionNamePattern.IsMatch(name); + + public bool IsApprovedVerb(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + + var dash = name.IndexOf('-'); + + if (dash <= 0) + return false; + + return ApprovedVerbs.Contains(name.Substring(0, dash)); + } + + private static IEnumerable GetAliasesFromScript(string script) + { + if (string.IsNullOrWhiteSpace(script)) + yield break; + + var ast = Parser.ParseInput(script, out _, out _); + + foreach (var command in ast.FindAll(a => a is CommandAst, true).Cast()) + { + var name = command.GetCommandName(); + + if (!string.Equals(name, "Set-Alias", StringComparison.OrdinalIgnoreCase) && + !string.Equals(name, "New-Alias", StringComparison.OrdinalIgnoreCase)) + continue; + + var elements = command.CommandElements; + + for (var i = 1; i < elements.Count; i++) + { + if (elements[i] is CommandParameterAst parameter && + string.Equals(parameter.ParameterName, "Name", StringComparison.OrdinalIgnoreCase) && + i + 1 < elements.Count) + { + var value = GetScalarString(elements[i + 1]); + + if (value != null) + yield return value; + + break; + } + + if (elements[i] is StringConstantExpressionAst positional && i == 1) + { + yield return positional.Value; + break; + } + } + } + } + + private static string GetScalarString(Ast valueAst) + { + return valueAst switch + { + StringConstantExpressionAst s => s.Value, + ExpandableStringExpressionAst e => e.Value, + ConstantExpressionAst c => c.Value?.ToString(), + _ => null, + }; + } + + private static string FormatArray(IReadOnlyList values) + { + if (values.Count == 0) + return "@()"; + + return "@(" + string.Join(", ", values.Select(v => $"'{Escape(v)}'")) + ")"; + } + + private static string Escape(string value) => + (value ?? string.Empty).Replace("'", "''"); + + private static string GetDefaultManifest(string name) => + $"@{{\n RootModule = '{name}.psm1'\n ModuleVersion = '1.0.0'\n GUID = '{Guid.NewGuid()}'\n FunctionsToExport = '*'\n}}\n"; + + private static string GetDefaultFunctionContent(string name) => + $"function {name} {{\n [CmdletBinding()]\n param()\n\n \"Hello from a LANCommander module\"\n}}\n"; + + // Loader that dot-sources every function file and exports only the public ones. + private static string GetLoaderScript() => + "$Public = @(Get-ChildItem -Path \"$PSScriptRoot\\Public\\*.ps1\" -ErrorAction SilentlyContinue)\n" + + "$Private = @(Get-ChildItem -Path \"$PSScriptRoot\\Private\\*.ps1\" -ErrorAction SilentlyContinue)\n" + + "\n" + + "foreach ($file in @($Public + $Private)) {\n" + + " try { . $file.FullName }\n" + + " catch { Write-Error \"Failed to import function $($file.FullName): $_\" }\n" + + "}\n" + + "\n" + + "Export-ModuleMember -Function $Public.BaseName\n"; + } +} diff --git a/LANCommander.Server.Services/ScriptService.cs b/LANCommander.Server.Services/ScriptService.cs index 92a31f45..98c47366 100644 --- a/LANCommander.Server.Services/ScriptService.cs +++ b/LANCommander.Server.Services/ScriptService.cs @@ -125,5 +125,62 @@ namespace LANCommander.Server.Services }; }); } + + private string GetSnippetsStoragePath() + { + var storagePath = settingsProvider.CurrentValue.Server.Scripts.Snippets.StoragePath; + + if (string.IsNullOrWhiteSpace(storagePath)) + { + storagePath = AppPaths.GetConfigPath("Snippets"); + + settingsProvider.Update(s => + { + s.Server.Scripts.Snippets.StoragePath = storagePath; + }); + } + + if (!Directory.Exists(storagePath)) + Directory.CreateDirectory(storagePath); + + return storagePath; + } + + private string GetSnippetPath(string group, string name) => + Path.Combine(GetSnippetsStoragePath(), group, $"{name}.ps1"); + + public Snippet GetSnippet(string group, string name) + { + var path = GetSnippetPath(group, name); + + if (!File.Exists(path)) + return null; + + return new Snippet + { + Group = group, + Name = name, + Content = File.ReadAllText(path), + }; + } + + public void SaveSnippet(Snippet snippet) + { + var path = GetSnippetPath(snippet.Group, snippet.Name); + var directory = Path.GetDirectoryName(path); + + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, snippet.Content ?? string.Empty); + } + + public void DeleteSnippet(string group, string name) + { + var path = GetSnippetPath(group, name); + + if (File.Exists(path)) + File.Delete(path); + } } } diff --git a/LANCommander.Server.Settings/Models/ModuleSettings.cs b/LANCommander.Server.Settings/Models/ModuleSettings.cs new file mode 100644 index 00000000..897caca4 --- /dev/null +++ b/LANCommander.Server.Settings/Models/ModuleSettings.cs @@ -0,0 +1,8 @@ +using LANCommander.SDK; + +namespace LANCommander.Server.Settings.Models; + +public class ModuleSettings +{ + public string StoragePath { get; set; } = AppPaths.GetConfigPath("Modules"); +} diff --git a/LANCommander.Server.Settings/Models/ScriptSettings.cs b/LANCommander.Server.Settings/Models/ScriptSettings.cs index 199522b6..44da2589 100644 --- a/LANCommander.Server.Settings/Models/ScriptSettings.cs +++ b/LANCommander.Server.Settings/Models/ScriptSettings.cs @@ -7,4 +7,5 @@ public class ScriptSettings public bool EnableAutomaticRepackaging { get; set; } = false; public int RepackageEvery { get; set; } = 24; public SnippetSettings Snippets { get; set; } = new(); + public ModuleSettings Modules { get; set; } = new(); } \ No newline at end of file diff --git a/LANCommander.Server/Endpoints/ModulesEndpoints.cs b/LANCommander.Server/Endpoints/ModulesEndpoints.cs new file mode 100644 index 00000000..285c4d80 --- /dev/null +++ b/LANCommander.Server/Endpoints/ModulesEndpoints.cs @@ -0,0 +1,32 @@ +using LANCommander.Server.Services; +using Microsoft.AspNetCore.Mvc; + +namespace LANCommander.Server.Endpoints; + +public static class ModulesEndpoints +{ + public static void MapModulesEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/Modules").RequireAuthorization(); + + group.MapGet("/", GetAsync); + group.MapGet("/Download", DownloadAsync); + } + + internal static IResult GetAsync([FromServices] ModuleService moduleService) + { + var names = moduleService.GetModules().Select(m => m.Name); + + return TypedResults.Ok(names); + } + + internal static IResult DownloadAsync([FromServices] ModuleService moduleService) + { + var archivePath = moduleService.GetModulesArchive(); + + var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, + FileOptions.DeleteOnClose | FileOptions.Asynchronous); + + return TypedResults.File(stream, "application/octet-stream", "Modules.zip"); + } +} diff --git a/LANCommander.Server/Startup/Endpoints.cs b/LANCommander.Server/Startup/Endpoints.cs index 0fa6e3f5..8fa27a1f 100644 --- a/LANCommander.Server/Startup/Endpoints.cs +++ b/LANCommander.Server/Startup/Endpoints.cs @@ -32,6 +32,7 @@ public static class Endpoints endpoints.MapLauncherEndpoints(); endpoints.MapLibraryEndpoints(); endpoints.MapRedistributablesEndpoints(); + endpoints.MapModulesEndpoints(); endpoints.MapToolsEndpoints(); endpoints.MapIssueEndpoints(); endpoints.MapSaveEndpoints(); diff --git a/LANCommander.Server/Startup/Filesystem.cs b/LANCommander.Server/Startup/Filesystem.cs index 8d2cca89..7ac0d11b 100644 --- a/LANCommander.Server/Startup/Filesystem.cs +++ b/LANCommander.Server/Startup/Filesystem.cs @@ -18,6 +18,7 @@ public static class Filesystem settings.Value.Server.Launcher.StoragePath, settings.Value.Server.Backups.StoragePath, settings.Value.Server.Scripts.Snippets.StoragePath, + settings.Value.Server.Scripts.Modules.StoragePath, ]; foreach (var directory in directories) diff --git a/LANCommander.Server/UI/Components/MainMenu.razor b/LANCommander.Server/UI/Components/MainMenu.razor index 5eb0fa53..05892443 100644 --- a/LANCommander.Server/UI/Components/MainMenu.razor +++ b/LANCommander.Server/UI/Components/MainMenu.razor @@ -57,6 +57,16 @@ Redistributables Tools Servers + + + + Scripting + + + Snippets + Modules + + diff --git a/LANCommander.Server/UI/Components/PowerShellScriptEditor.razor b/LANCommander.Server/UI/Components/PowerShellScriptEditor.razor new file mode 100644 index 00000000..57f82ff4 --- /dev/null +++ b/LANCommander.Server/UI/Components/PowerShellScriptEditor.razor @@ -0,0 +1,91 @@ +@inject ScriptService ScriptService +@inject ModuleService ModuleService + + + @foreach (var group in Snippets.Select(s => s.Group).Distinct()) + { + + + + @foreach (var snippet in Snippets.Where(s => s.Group == group)) + { + + @snippet.Name + + } + + + + + + + + } + + @if (Variables != null && Variables.Any()) + { + + + + @foreach (var variable in Variables) + { + + @variable + + } + + + + + + + + } + + + + +@code { + [Parameter] public string? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public int Height { get; set; } = 500; + [Parameter] public IEnumerable? Variables { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + + CodeInput? _codeInput; + IEnumerable Snippets { get; set; } = []; + List _moduleFunctions = new(); + + protected override void OnInitialized() + { + Snippets = ScriptService.GetSnippets(); + + _moduleFunctions = ModuleService.GetPublicFunctionCompletions() + .Select(f => new ModuleFunctionCompletion(f.Name, string.IsNullOrWhiteSpace(f.Synopsis) ? null : f.Synopsis, f.Module)) + .ToList(); + } + + public async Task LayoutAsync() + { + if (_codeInput != null) + await _codeInput.LayoutAsync(); + } + + async Task InsertText(string text) + { + if (_codeInput != null) + await _codeInput.InsertText(text); + } + + async Task InsertSnippet(Snippet snippet) + { + if (_codeInput != null) + await _codeInput.InsertSnippet(snippet.Content); + } +} diff --git a/LANCommander.Server/UI/Components/ScriptEditorDialog.razor b/LANCommander.Server/UI/Components/ScriptEditorDialog.razor index 087de700..0a51e182 100644 --- a/LANCommander.Server/UI/Components/ScriptEditorDialog.razor +++ b/LANCommander.Server/UI/Components/ScriptEditorDialog.razor @@ -6,6 +6,7 @@ @using YamlDotNet.Serialization.NamingConventions @inherits FeedbackComponent @inject ScriptService ScriptService +@inject ModuleService ModuleService @inject ArchiveService ArchiveService @inject ServerService ServerService @inject RedistributableService RedistributableService @@ -91,7 +92,7 @@ Requires Admin - + @@ -113,6 +114,7 @@ CodeInput? _codeInput; PowerShellConsole? _console; IEnumerable Snippets { get; set; } = []; + List _moduleFunctions = new(); List _variables = new(); List _optionKeys = new(); string _redistributableName = string.Empty; @@ -209,6 +211,10 @@ UpdateVariables(); Snippets = ScriptService.GetSnippets(); + + _moduleFunctions = ModuleService.GetPublicFunctionCompletions() + .Select(f => new ModuleFunctionCompletion(f.Name, string.IsNullOrWhiteSpace(f.Synopsis) ? null : f.Synopsis, f.Module)) + .ToList(); } async Task OnEditorReady() diff --git a/LANCommander.Server/UI/Pages/Scripting/Modules/Edit.razor b/LANCommander.Server/UI/Pages/Scripting/Modules/Edit.razor new file mode 100644 index 00000000..9871fd4a --- /dev/null +++ b/LANCommander.Server/UI/Pages/Scripting/Modules/Edit.razor @@ -0,0 +1,388 @@ +@page "/Scripting/Modules/Add" +@page "/Scripting/Modules/{Name}" +@attribute [Authorize(Roles = RoleService.AdministratorRoleName)] +@inject ModuleService ModuleService +@inject NavigationManager NavigationManager +@inject IMessageService MessageService +@inject ILogger Logger + + + + + + + + + + + + + +
+ + + @if (_activeTab == "functions") + { + + + + + + + + + + + @foreach (var function in Module.Functions.Where(f => f.Visibility == FunctionVisibility.Public)) + { + + } + + + + + @foreach (var function in Module.Functions.Where(f => f.Visibility == FunctionVisibility.Private)) + { + + } + + + + + + + + @if (_selectedFunction != null) + { + + + + + + + +