From 708a7bb02dd16385b6178f052403ff3a2e0f54f6 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 06:32:42 -0400 Subject: [PATCH 01/12] logging and icon validation --- PaletteShellExtension/Classes/Log.cs | 80 +++++++++++++++++++ .../Classes/ScriptOutputHandler.cs | 4 +- PaletteShellExtension/Classes/ScriptRunner.cs | 11 ++- .../Pages/PaletteShellExtensionPage.cs | 11 +-- .../PowerShellScriptParser.cs | 50 +++++++++++- 5 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 PaletteShellExtension/Classes/Log.cs diff --git a/PaletteShellExtension/Classes/Log.cs b/PaletteShellExtension/Classes/Log.cs new file mode 100644 index 0000000..1566cb7 --- /dev/null +++ b/PaletteShellExtension/Classes/Log.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; + +namespace PaletteShellExtension.Classes; + +/// +/// Minimal file logger for diagnosing script-loading and execution failures. The extension +/// runs as a COM server hosted by Command Palette, so there is no console the user can see — +/// this is the only way to learn *why* a script failed to parse or run without attaching a +/// debugger. Every write is best-effort; logging must never be the thing that crashes the +/// extension. +/// +internal static class Log +{ + private static readonly object WriteLock = new(); + private static readonly string LogPath; + + static Log() + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PaletteShell", + "logs"); + + LogPath = Path.Combine(dir, $"palette-shell-{DateTime.Now:yyyyMMdd}.log"); + + try + { + Directory.CreateDirectory(dir); + PruneOldLogs(dir); + } + catch (Exception) + { + // If we can't even create the log directory, logging is a no-op for this session. + } + } + + public static void Info(string message) => Write("INFO", message); + + public static void Warn(string message) => Write("WARN", message); + + public static void Error(string message, Exception? exception = null) => + Write("ERROR", exception is null ? message : $"{message}: {exception}"); + + private static void Write(string level, string message) + { + try + { + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; + lock (WriteLock) + { + File.AppendAllText(LogPath, line); + } + } + catch (Exception) + { + // Never let logging failures surface to the caller. + } + } + + // Keeps the log directory from growing forever — one file per day, last week retained. + private static void PruneOldLogs(string dir) + { + var cutoff = DateTime.Now.AddDays(-7); + foreach (var file in Directory.GetFiles(dir, "palette-shell-*.log")) + { + try + { + if (File.GetLastWriteTime(file) < cutoff) + { + File.Delete(file); + } + } + catch (Exception) + { + // A locked or already-removed file just gets picked up next time. + } + } + } +} diff --git a/PaletteShellExtension/Classes/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs index 25dfc21..b252ed2 100644 --- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs +++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs @@ -50,9 +50,9 @@ private static void TrySetClipboard(string text) { TextCopy.ClipboardService.SetText(text ?? ""); } - catch (Exception) + catch (Exception ex) { - // Clipboard access can fail; ignore and continue. + Log.Warn($"Failed to set clipboard text: {ex.Message}"); } } } diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index bc76a9c..66b8f88 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -195,8 +195,9 @@ public static bool RunScript( using var proc = Process.Start(psi); return proc is not null; } - catch (Exception) + catch (Exception ex) { + Log.Error($"Failed to launch script '{scriptPath}'", ex); return false; } } @@ -257,6 +258,7 @@ public static bool RunScript( if (timeoutMs.HasValue && !proc.WaitForExit(timeoutMs.Value)) { result.TimedOut = true; + Log.Warn($"Script '{scriptPath}' timed out after {timeoutMs.Value}ms and was killed"); try { proc.Kill(entireProcessTree: true); } catch (Exception) { @@ -279,10 +281,15 @@ public static bool RunScript( result.StandardOutput = AwaitRead(stdoutTask); result.StandardError = AwaitRead(stderrTask); result.ExitCode = proc.ExitCode; + if (result.ExitCode != 0) + { + Log.Warn($"Script '{scriptPath}' exited with code {result.ExitCode}"); + } return result; } - catch (Exception) + catch (Exception ex) { + Log.Error($"Failed to run script '{scriptPath}'", ex); return null; } finally diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index 81447c6..a1069ef 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -82,9 +82,9 @@ private void CopySampleScripts() File.WriteAllText(targetPath, content, new UTF8Encoding(false)); } } - catch (Exception) + catch (Exception ex) { - // Skip scripts that fail to copy. + Log.Warn($"Failed to copy sample script '{fileName}': {ex.Message}"); } } } @@ -104,9 +104,9 @@ private void CopyPowerShellModule() { File.Copy(moduleSourcePath, moduleTargetPath, overwrite: true); } - catch (Exception) + catch (Exception ex) { - // Module copy is best-effort. + Log.Warn($"Failed to copy PaletteScriptAttributes.psm1 to scripts folder: {ex.Message}"); } } @@ -256,11 +256,12 @@ public override IListItem[] GetItems() scriptItems.Add((pinned, title, listItem)); } - catch (Exception) + catch (Exception ex) { // Building the rich entry failed (e.g. a malformed parameter block). Rather than // dropping the script silently, surface it with an error hint so the user can // find it, open it to fix, or remove it — instead of wondering where it went. + Log.Warn($"Failed to build list item for '{path}': {ex.Message}"); var pinned = _pins.IsPinned(path); var errorTitle = Path.GetFileNameWithoutExtension(path); scriptItems.Add((pinned, errorTitle, new ListItem(new OpenInEditorCommand(path)) diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index 8f79f22..436dd8f 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -94,8 +94,9 @@ internal static partial class PowerShellScriptParser return manifest; } - catch (Exception) + catch (Exception ex) { + Log.Warn($"Failed to parse manifest for '{ps1Path}': {ex.Message}"); return null; } } @@ -247,7 +248,18 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) manifest.Group = values[0]; break; case "ScriptIcon" when values.Count >= 1: - manifest.IconGlyph = values[0]; + if (string.IsNullOrEmpty(values[0])) + { + // Explicit empty glyph is a no-op, same as omitting the attribute. + } + else if (IsValidIconGlyph(values[0])) + { + manifest.IconGlyph = values[0]; + } + else + { + Log.Warn($"Ignoring invalid ScriptIcon value {EscapeForLog(values[0])} (expected a single glyph)"); + } break; case "ScriptEnv" when values.Count >= 2: manifest.Env[values[0]] = values[1]; @@ -654,6 +666,40 @@ private static (string Name, string? Args) SplitNameArgs(string group) return (name, args); } + /// + /// True when looks like a single icon glyph rather than a word + /// or sentence someone mistakenly passed to [ScriptIcon(...)]. Accepts both a lone + /// Segoe Fluent/MDL2 PUA codepoint and a multi-codepoint emoji sequence (skin tone + /// modifiers, ZWJ joins, flags), since those combine into a single rendered glyph even + /// though they span several chars. + /// + private static bool IsValidIconGlyph(string glyph) + { + if (string.IsNullOrEmpty(glyph) || glyph.Length > 32) + { + return false; + } + + foreach (var c in glyph) + { + if (char.IsControl(c) || char.IsWhiteSpace(c)) + { + return false; + } + } + + return new StringInfo(glyph).LengthInTextElements == 1; + } + + /// Renders a string safely for a log line: escapes control characters and caps length. + private static string EscapeForLog(string value) + { + const int max = 40; + var truncated = value.Length > max ? value[..max] + "…" : value; + return "'" + truncated.Replace("\\", "\\\\").Replace("'", "\\'") + .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t") + "'"; + } + /// Trims one matching pair of surrounding quotes and unescapes doubled quotes. private static string StripQuotes(string value) { From 408a7275fa41ae35989a015aaf920bfc77c26a30 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 06:51:17 -0400 Subject: [PATCH 02/12] Adding unit tests --- Directory.Packages.props | 3 + .../PaletteShellExtension.Tests.csproj | 27 + .../PowerShellScriptParserTests.cs | 466 ++++++++++++++++++ PaletteShellExtension.Tests/TestScriptFile.cs | 21 + PaletteShellExtension.sln | 28 +- .../PaletteShellExtension.csproj | 4 + 6 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj create mode 100644 PaletteShellExtension.Tests/PowerShellScriptParserTests.cs create mode 100644 PaletteShellExtension.Tests/TestScriptFile.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5fc3fbf..630e991 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,9 @@ + + + diff --git a/PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj b/PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj new file mode 100644 index 0000000..86e3b23 --- /dev/null +++ b/PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0-windows10.0.26100.0 + 10.0.19041.0 + 10.0.26100.68-preview + enable + false + true + + $(NoWarn);CA1707 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs new file mode 100644 index 0000000..6858bbc --- /dev/null +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -0,0 +1,466 @@ +using System.IO; +using System.Linq; +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class PowerShellScriptParserTests +{ + // ----- Comment-based help ----------------------------------------------------------- + + [Fact] + public void Synopsis_BecomesTitle() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + My Cool Script + #> + Write-Output "hi" + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Equal("My Cool Script", manifest!.Title); + } + + [Fact] + public void MissingSynopsis_FallsBackToFileName() + { + using var file = new TestScriptFile("Write-Output \"hi\""); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Equal(Path.GetFileNameWithoutExtension(file.Path), manifest!.Title); + } + + [Fact] + public void Description_IsParsed() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + Title + .DESCRIPTION + Does a thing. + #> + Write-Output "hi" + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("Does a thing.", manifest!.Description); + } + + [Fact] + public void ParameterHelp_BecomesLabel_WhenNoHelpMessage() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + Title + .PARAMETER Name + The name to greet. + #> + param( + [string]$Name + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal("The name to greet.", parameter.Label); + } + + // ----- Parameters --------------------------------------------------------------------- + + [Theory] + [InlineData("[string]$P", "string")] + [InlineData("[int]$P", "int")] + [InlineData("[int32]$P", "int")] + [InlineData("[long]$P", "int")] + [InlineData("[double]$P", "number")] + [InlineData("[decimal]$P", "number")] + [InlineData("[switch]$P", "bool")] + [InlineData("[bool]$P", "bool")] + [InlineData("$P", "string")] // no type constraint at all + public void ParameterType_MapsToUiType(string declaration, string expectedUiType) + { + using var file = new TestScriptFile($"param(\n {declaration}\n)"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(expectedUiType, parameter.Type); + } + + [Fact] + public void ValidateSet_ProducesEnumWithOptions() + { + using var file = new TestScriptFile(""" + param( + [ValidateSet('A','B','C')] + [string]$Choice + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal("enum", parameter.Type); + Assert.Equal(["A", "B", "C"], parameter.Options); + } + + [Fact] + public void ValidateRange_SetsMinAndMax() + { + using var file = new TestScriptFile(""" + param( + [ValidateRange(1,10)] + [int]$Count + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(1, parameter.Min); + Assert.Equal(10, parameter.Max); + } + + [Theory] + [InlineData("[Parameter(Mandatory=$true)]", true)] + [InlineData("[Parameter(Mandatory)]", true)] + [InlineData("[Parameter(Mandatory=$false)]", false)] + [InlineData("", false)] + public void Mandatory_SetsRequired(string attribute, bool expectRequired) + { + using var file = new TestScriptFile($"param(\n {attribute}\n [string]$P\n)"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(expectRequired ? true : (bool?)null, parameter.Required); + } + + [Fact] + public void HelpMessage_OverridesParameterHelpText() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + Title + .PARAMETER Name + Comment-based help text. + #> + param( + [Parameter(HelpMessage='Explicit label')] + [string]$Name + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal("Explicit label", parameter.Label); + } + + [Fact] + public void AllowExpression_SetsFlag() + { + using var file = new TestScriptFile(""" + param( + [AllowExpression()] + [string]$Expr + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.True(parameter.AllowExpression); + } + + [Theory] + [InlineData("[int]$P = 5", 5)] + [InlineData("[string]$P = \"hi\"", "hi")] + [InlineData("[bool]$P = $true", true)] + [InlineData("[bool]$P = $false", false)] + public void DefaultValue_IsInterpretedByType(string declaration, object expected) + { + using var file = new TestScriptFile($"param(\n {declaration}\n)"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(expected, parameter.Default); + } + + [Fact] + public void MultipleParameters_AllParsed() + { + using var file = new TestScriptFile(""" + param( + [string]$Name, + [int]$Count = 3, + [switch]$Force + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(3, manifest!.Parameters.Count); + Assert.Equal(["Name", "Count", "Force"], manifest.Parameters.Select(p => p.Name)); + } + + // ----- Script-level attributes ---------------------------------------------------------- + + [Fact] + public void ScriptHost_IsParsed() + { + using var file = new TestScriptFile("[ScriptHost('powershell')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("powershell", manifest!.Host); + } + + [Fact] + public void ScriptCwd_IsParsed() + { + using var file = new TestScriptFile("[ScriptCwd('{ScriptDir}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("{ScriptDir}", manifest!.Cwd); + } + + [Fact] + public void RequiresElevationAttribute_SetsRequiresAdmin() + { + using var file = new TestScriptFile("[RequiresElevation()]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.True(manifest!.RequiresAdmin); + } + + [Fact] + public void RequiresAdminComment_SetsRequiresAdmin() + { + using var file = new TestScriptFile("#Requires -RunAsAdministrator\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.True(manifest!.RequiresAdmin); + } + + [Theory] + [InlineData("[ConfirmBeforeRun()]", "Are you sure you want to run this script?")] + [InlineData("[ConfirmBeforeRun]", "Are you sure you want to run this script?")] + [InlineData("[ConfirmBeforeRun('This deletes files')]", "This deletes files")] + public void ConfirmBeforeRun_SetsMessage(string attribute, string expectedMessage) + { + using var file = new TestScriptFile($"{attribute}\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(expectedMessage, manifest!.ConfirmMessage); + } + + [Fact] + public void ScriptTimeout_IsParsed() + { + using var file = new TestScriptFile("[ScriptTimeout(30000)]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(30000, manifest!.TimeoutMs); + } + + [Fact] + public void ScriptOutput_WithoutExtension_SetsOutputOnly() + { + using var file = new TestScriptFile("[ScriptOutput('Clipboard')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("Clipboard", manifest!.Output); + Assert.Null(manifest.FileExtension); + } + + [Fact] + public void ScriptOutput_WithExtensionHint_SplitsOutputAndExtension() + { + using var file = new TestScriptFile("[ScriptOutput('File:csv')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("File", manifest!.Output); + Assert.Equal("csv", manifest.FileExtension); + } + + [Fact] + public void ScriptGroup_IsParsed() + { + using var file = new TestScriptFile("[ScriptGroup('Utilities')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("Utilities", manifest!.Group); + } + + [Fact] + public void ScriptEnv_MultipleAttributes_AllCaptured() + { + using var file = new TestScriptFile(""" + [ScriptEnv('KEY1','value1')] + [ScriptEnv('KEY2','value2')] + param() + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("value1", manifest!.Env["KEY1"]); + Assert.Equal("value2", manifest.Env["KEY2"]); + } + + // ----- Icon glyph validation ------------------------------------------------------------- + // + // Glyphs below are built from Unicode escapes rather than typed as literal characters: + // Segoe MDL2/Fluent PUA codepoints and ZWJ emoji sequences are invisible or easily + // mis-typed in an editor, which is exactly the failure mode this validation guards + // against (see PowerShellScriptParser.IsValidIconGlyph). + + [Fact] + public void ScriptIcon_SingleEmoji_IsAccepted() + { + var glyph = "\U0001F680"; // rocket + using var file = new TestScriptFile($"[ScriptIcon('{glyph}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(glyph, manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_SinglePuaGlyph_IsAccepted() + { + var glyph = "\uE8B7"; // Segoe MDL2 Assets glyph + using var file = new TestScriptFile($"[ScriptIcon('{glyph}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(glyph, manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_ZwjEmojiSequence_IsAcceptedAsSingleGrapheme() + { + // Family emoji: three codepoints joined by ZWJ (U+200D), rendering as one glyph. + var glyph = "\U0001F468\u200D\U0001F469\u200D\U0001F467"; + using var file = new TestScriptFile($"[ScriptIcon('{glyph}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(glyph, manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_MultiWordText_IsRejected() + { + using var file = new TestScriptFile("[ScriptIcon('todo icon please')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_Empty_IsNoOp() + { + using var file = new TestScriptFile("[ScriptIcon('')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.IconGlyph); + } + + // ----- Robustness ------------------------------------------------------------------------ + + [Fact] + public void NonexistentFile_ReturnsNull() + { + var manifest = PowerShellScriptParser.TryParseManifest( + Path.Combine(Path.GetTempPath(), $"does-not-exist-{System.Guid.NewGuid():N}.ps1")); + + Assert.Null(manifest); + } + + [Fact] + public void NoParamBlock_ReturnsManifestWithNoParameters() + { + using var file = new TestScriptFile("Write-Output \"no params here\""); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Empty(manifest!.Parameters); + } + + [Fact] + public void UnbalancedParamBlock_DoesNotThrow_AndYieldsNoParameters() + { + using var file = new TestScriptFile("param(\n [string]$Name"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Empty(manifest!.Parameters); + } + + // ----- Path token expansion --------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExpandPathTokens_NullOrWhitespace_PassesThrough(string? input) + { + var result = PowerShellScriptParser.ExpandPathTokens(input, @"C:\scripts\test.ps1"); + + Assert.Equal(input, result); + } + + [Fact] + public void ExpandPathTokens_ScriptDir_ExpandsToScriptDirectory() + { + var result = PowerShellScriptParser.ExpandPathTokens("{ScriptDir}\\out.txt", @"C:\scripts\test.ps1"); + + Assert.Equal(@"C:\scripts\out.txt", result); + } + + [Fact] + public void ExpandPathTokens_Home_ExpandsToUserProfile() + { + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + + var result = PowerShellScriptParser.ExpandPathTokens("{Home}\\notes.txt", @"C:\scripts\test.ps1"); + + Assert.Equal(home + "\\notes.txt", result); + } + + [Fact] + public void ExpandPathTokens_Temp_ExpandsToTempPath() + { + var temp = Path.GetTempPath(); + + var result = PowerShellScriptParser.ExpandPathTokens("{Temp}file.txt", @"C:\scripts\test.ps1"); + + Assert.Equal(temp + "file.txt", result); + } +} diff --git a/PaletteShellExtension.Tests/TestScriptFile.cs b/PaletteShellExtension.Tests/TestScriptFile.cs new file mode 100644 index 0000000..913a64b --- /dev/null +++ b/PaletteShellExtension.Tests/TestScriptFile.cs @@ -0,0 +1,21 @@ +using System; +using System.IO; + +namespace PaletteShellExtension.Tests; + +/// Writes a temporary .ps1 file for a test and deletes it on dispose. +internal sealed class TestScriptFile : IDisposable +{ + public string Path { get; } + + public TestScriptFile(string content) + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"pstest-{Guid.NewGuid():N}.ps1"); + File.WriteAllText(Path, content, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + public void Dispose() + { + try { File.Delete(Path); } catch { /* best-effort cleanup */ } + } +} diff --git a/PaletteShellExtension.sln b/PaletteShellExtension.sln index 8632d80..4969403 100644 --- a/PaletteShellExtension.sln +++ b/PaletteShellExtension.sln @@ -1,18 +1,22 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.13.35507.96 d17.13 +VisualStudioVersion = 17.13.35507.96 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteShellExtension", "PaletteShellExtension\PaletteShellExtension.csproj", "{79F86DE5-70B1-4EC1-9832-DF428B55E466}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteShellExtension.Tests", "PaletteShellExtension.Tests\PaletteShellExtension.Tests.csproj", "{B3E8D0AB-C335-4D95-8F08-E705407147AC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 + Debug|Any CPU = Debug|Any CPU Release|ARM64 = Release|ARM64 Release|x64 = Release|x64 Release|x86 = Release|x86 + Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|ARM64.ActiveCfg = Debug|ARM64 @@ -24,6 +28,8 @@ Global {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.ActiveCfg = Debug|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Build.0 = Debug|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Deploy.0 = Debug|x86 + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|Any CPU.Build.0 = Debug|Any CPU {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.ActiveCfg = Release|ARM64 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Build.0 = Release|ARM64 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Deploy.0 = Release|ARM64 @@ -33,6 +39,24 @@ Global {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.ActiveCfg = Release|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Build.0 = Release|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Deploy.0 = Release|x86 + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|Any CPU.Build.0 = Release|Any CPU + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|ARM64.Build.0 = Debug|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x64.ActiveCfg = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x64.Build.0 = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x86.ActiveCfg = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x86.Build.0 = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|Any CPU.ActiveCfg = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|Any CPU.Build.0 = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|ARM64.ActiveCfg = Release|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|ARM64.Build.0 = Release|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x64.ActiveCfg = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x64.Build.0 = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x86.ActiveCfg = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x86.Build.0 = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|Any CPU.ActiveCfg = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|Any CPU.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 0661dba..8a9bef2 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -31,6 +31,10 @@ + + + + From 95820996e63662741f6cd6629d47baa94752aa3e Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 06:54:44 -0400 Subject: [PATCH 03/12] Add CI workflow to test PR's --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0659339 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + platform: [x64, ARM64] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore PaletteShellExtension.sln + + - name: Build + run: dotnet build PaletteShellExtension.sln -c Release -p:Platform=${{ matrix.platform }} --no-restore + + test: + name: Test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore PaletteShellExtension.sln + + - name: Test + run: dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 --no-restore --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/TestResults/test-results.trx' + retention-days: 7 From 0a2c03b4845ff46c5050572306c09832bff78284 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 07:07:05 -0400 Subject: [PATCH 04/12] Adding contribute information --- CONTRIBUTING.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e43ae5e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing to PaletteShell + +This covers building, testing, and debugging the **extension itself** (the C# host). If you're +looking to write a PaletteShell *script*, see the [README](README.md#-creating-your-own-scripts) +and [AGENTS.md](AGENTS.md) instead — this doc is for people changing the extension's code. + +## Prerequisites + +- .NET 9 SDK with the Windows 10.0.26100 platform (`dotnet --list-sdks` should show a `9.0.x` entry) +- Windows 10 (10.0.19041) or later +- [Windows Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview) + (part of PowerToys) installed, for the sideload/debug loop below +- Visual Studio 2022 (17.13+) with the "Windows application development" workload, only needed for + MSIX packaging and process-attach debugging — everyday build/test/edit works from the CLI alone + +## Project layout + +Two projects, one solution (`PaletteShellExtension.sln`): + +- `PaletteShellExtension/` — the extension itself (see the project structure table in the + [README](README.md#project-structure) for what each file does) +- `PaletteShellExtension.Tests/` — xUnit tests, currently focused on `PowerShellScriptParser` + +Both target `net9.0-windows10.0.26100.0` and only build for `x64`/`ARM64` (no `x86`/`AnyCPU`) — +that's enforced by `Directory.Build.props` at the repo root, so pass `-p:Platform=x64` (or +`ARM64`) to any `dotnet build`/`test` invocation. + +## Building and testing + +```powershell +dotnet restore PaletteShellExtension.sln +dotnet build PaletteShellExtension.sln -c Debug -p:Platform=x64 +dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 +``` + +For most changes — anything in `PowerShellScriptParser`, the manifest models, or output +handling — `dotnet test` is the fast loop: it doesn't need Command Palette, sideloading, or a +debugger attached. Add cases to `PaletteShellExtension.Tests/PowerShellScriptParserTests.cs` +alongside behavior changes. + +`.github/workflows/ci.yml` runs the same build (both platforms, Release) and test suite on every +PR, so a green `dotnet build`/`dotnet test` locally is a reliable predictor of CI passing. + +## Running and debugging the extension locally + +Open `PaletteShellExtension.sln` in Visual Studio with `PaletteShellExtension` as the startup +project and press **F5**. VS's single-project MSIX tooling (`EnableMsixTooling`, +`HasPackageAndPublishMenu` in the `.csproj`) handles build, sideload deployment, and registering +the COM server in one step, and arms the debugger for it — when Command Palette activates +PaletteShell, breakpoints just hit. No manual `Attach to Process` needed. + +Open Command Palette and search **PaletteShell** to trigger activation. If you're mid-debug +session and want to force a fresh activation after a code change, stop debugging, rebuild, and F5 +again — VS redeploys the updated package. + +For issues you can't easily catch with a debugger (e.g. something a user reports), check the +extension's log file instead — parse failures, icon-validation warnings, and script execution +errors are written to `%LOCALAPPDATA%\PaletteShell\logs\palette-shell-.log` +(see `Classes/Log.cs`), which is pruned automatically after 7 days. + +### Producing a standalone test package + +To hand someone a sideloadable build without Visual Studio attached (or to test the packaged +artifact itself), right-click the `PaletteShellExtension` project → **Publish → Create App +Packages...** → choose **Sideloading** → build for the platform you need (`x64` or `ARM64`). This +produces +`PaletteShellExtension/AppPackages//PaletteShellExtension___Test/`, +containing the `.msix` and an `Add-AppDevPackage.ps1` installer script — run that script (as your +normal user, not elevated) to install it. On first use it also installs the developer certificate +needed to trust the unsigned test package. + +## Bumping the package version + +`AppxPackageVersion` in `PaletteShellExtension.csproj` and `` in +`Package.appxmanifest` must match — bump both together. + +## Submitting a change + +- Keep `dotnet build` and `dotnet test` (both platforms if the change could plausibly differ + between them) green before opening a PR; CI re-checks the same thing. +- If you change parsing or manifest behavior, add or update a test in + `PaletteShellExtension.Tests` rather than relying on manual sideload testing alone. +- If you change end-user-facing behavior (attributes, output modes, sample scripts), update the + relevant section of [README.md](README.md) and, if it affects script authoring, [AGENTS.md](AGENTS.md) + in the same PR — they're both meant to stay authoritative. diff --git a/README.md b/README.md index 0626b3c..7f5c517 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,8 @@ For more, browse the community library at **[paletteshell/PaletteShellScripts](h ## 🛠️ Building from Source -PaletteShell is a .NET 9 Windows app packaged as an MSIX Command Palette extension. +PaletteShell is a .NET 9 Windows app packaged as an MSIX Command Palette extension. For running +tests, sideloading, and debugging the extension itself, see [CONTRIBUTING.md](CONTRIBUTING.md). **Requirements** From 54445974024030168145449f9fc9107543f1c1d9 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 07:15:38 -0400 Subject: [PATCH 05/12] Validation on some input fields --- .../PowerShellScriptParserTests.cs | 64 +++++++++++ .../ScriptParameterFormTests.cs | 106 ++++++++++++++++++ .../Commands/RunScriptCommand.cs | 2 +- .../Forms/ScriptParameterForm.cs | 22 ++++ .../Pages/PaletteShellExtensionPage.cs | 8 +- .../PowerShellScriptParser.cs | 51 ++++++++- 6 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 PaletteShellExtension.Tests/ScriptParameterFormTests.cs diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs index 6858bbc..1ef0f1d 100644 --- a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -282,6 +282,29 @@ public void ScriptTimeout_IsParsed() Assert.Equal(30000, manifest!.TimeoutMs); } + [Theory] + [InlineData(-5)] + [InlineData(0)] + [InlineData(999)] + public void ScriptTimeout_BelowMinimum_IsIgnored(int timeoutMs) + { + using var file = new TestScriptFile($"[ScriptTimeout({timeoutMs})]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.TimeoutMs); + } + + [Fact] + public void ScriptTimeout_AboveMaximum_IsClamped() + { + using var file = new TestScriptFile("[ScriptTimeout(999999999)]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(600_000, manifest!.TimeoutMs); + } + [Fact] public void ScriptOutput_WithoutExtension_SetsOutputOnly() { @@ -463,4 +486,45 @@ public void ExpandPathTokens_Temp_ExpandsToTempPath() Assert.Equal(temp + "file.txt", result); } + + [Fact] + public void ResolveCwd_NonexistentDirectory_ReturnsNull() + { + var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{System.Guid.NewGuid():N}"); + + var result = PowerShellScriptParser.ResolveCwd(missing, @"C:\scripts\test.ps1"); + + Assert.Null(result); + } + + [Fact] + public void ResolveCwd_ExistingDirectory_ReturnsExpandedPath() + { + var temp = Path.GetTempPath(); + + var result = PowerShellScriptParser.ResolveCwd("{Temp}", @"C:\scripts\test.ps1"); + + Assert.Equal(temp, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveCwd_NullOrEmpty_PassesThrough(string? input) + { + var result = PowerShellScriptParser.ResolveCwd(input, @"C:\scripts\test.ps1"); + + Assert.Equal(input, result); + } + + [Fact] + public void ResolveCwd_ScriptDirOfRealScript_Resolves() + { + using var file = new TestScriptFile("param()"); + var expectedDir = Path.GetDirectoryName(file.Path); + + var result = PowerShellScriptParser.ResolveCwd("{ScriptDir}", file.Path); + + Assert.Equal(expectedDir, result); + } } diff --git a/PaletteShellExtension.Tests/ScriptParameterFormTests.cs b/PaletteShellExtension.Tests/ScriptParameterFormTests.cs new file mode 100644 index 0000000..0882e0d --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptParameterFormTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using PaletteShellExtension.Forms; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptParameterFormTests +{ + private const string ScriptPath = @"C:\scripts\test.ps1"; + + private static ScriptManifest ManifestWithRequiredName(string? label = null) => new() + { + Title = "Test", + Parameters = [new ScriptParameter { Name = "Name", Type = "string", Required = true, Label = label }] + }; + + // ----- GetMissingRequiredFields (pure logic, no script execution) ----------------------- + + [Fact] + public void GetMissingRequiredFields_EmptyValue_IsReportedMissing() + { + var manifest = ManifestWithRequiredName(); + var values = JsonNode.Parse("""{"Name":""}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Equal(["Name"], missing); + } + + [Fact] + public void GetMissingRequiredFields_WhitespaceOnlyValue_IsReportedMissing() + { + var manifest = ManifestWithRequiredName(); + var values = JsonNode.Parse("""{"Name":" "}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Equal(["Name"], missing); + } + + [Fact] + public void GetMissingRequiredFields_UsesLabelWhenPresent() + { + var manifest = ManifestWithRequiredName(label: "Full Name"); + var values = JsonNode.Parse("""{"Name":""}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Equal(["Full Name"], missing); + } + + [Fact] + public void GetMissingRequiredFields_FilledRequiredField_IsNotReported() + { + var manifest = ManifestWithRequiredName(); + var values = JsonNode.Parse("""{"Name":"Alice"}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Empty(missing); + } + + [Fact] + public void GetMissingRequiredFields_OptionalFieldEmpty_IsNotReported() + { + var manifest = new ScriptManifest + { + Title = "Test", + Parameters = [new ScriptParameter { Name = "Name", Type = "string", Required = null }] + }; + var values = JsonNode.Parse("""{"Name":""}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Empty(missing); + } + + // ----- SubmitForm early-exit paths (don't reach script execution) ----------------------- + + [Fact] + public void SubmitForm_MissingRequiredField_KeepsFormOpenWithToast() + { + var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName()); + + var result = form.SubmitForm("""{"Name":""}""", """{"verb":"run"}"""); + + Assert.Equal(CommandResultKind.ShowToast, result.Kind); + var toastArgs = Assert.IsType(result.Args); + Assert.Contains("Name", toastArgs.Message); + Assert.NotNull(toastArgs.Result); + Assert.Equal(CommandResultKind.KeepOpen, toastArgs.Result!.Kind); + } + + [Fact] + public void SubmitForm_Cancel_Dismisses() + { + var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName()); + + var result = form.SubmitForm("""{"Name":""}""", """{"verb":"cancel"}"""); + + Assert.Equal(CommandResultKind.Dismiss, result.Kind); + } +} diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index 3cc495d..1eeb6b4 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -43,7 +43,7 @@ private CommandResult RunNow() var wantsAdmin = _manifest.RequiresAdmin == true; // CWD - var cwd = ExpandPathTokens(_manifest.Cwd, path); + var cwd = PowerShellScriptParser.ResolveCwd(_manifest.Cwd, path); // Env var expandedEnv = new Dictionary(); diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index f7723c7..29d7470 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -57,6 +57,20 @@ public override CommandResult SubmitForm(string inputs, string data) var obj = JsonNode.Parse(inputs)?.AsObject(); if (obj is null) return CommandResult.Dismiss(); + // The Adaptive Card's `isRequired` flag is enforced client-side by the host, but + // that isn't guaranteed for every input type or host version — this is the + // server-side backstop so a mandatory parameter can never reach the script empty. + var missing = GetMissingRequiredFields(_manifest, obj); + + if (missing.Count > 0) + { + return CommandResult.ShowToast(new ToastArgs + { + Message = $"Required: {string.Join(", ", missing)}", + Result = CommandResult.KeepOpen() + }); + } + // Build argument list from form values var args = new List(); foreach (var param in _manifest.Parameters) @@ -218,6 +232,14 @@ private CommandResult Execute(string argsLine) } } + /// Returns the label/name of each required parameter whose submitted value is + /// empty or whitespace-only. + internal static List GetMissingRequiredFields(ScriptManifest manifest, JsonObject values) => + manifest.Parameters + .Where(p => p.Required == true && string.IsNullOrWhiteSpace(values[p.Name]?.ToString())) + .Select(p => p.Label ?? p.Name) + .ToList(); + private string BuildTemplateJson() { // Build the body as a list of nodes, then materialize via the JsonArray(params) diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index a1069ef..134b46f 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -182,7 +182,7 @@ public override IListItem[] GetItems() // stdout into a searchable, pickable list. If the script declares a // parameter, that page feeds it the palette's search text (it acts as a // live provider) rather than using the parameter form. - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); command = new ScriptListPage( scriptPath: path, @@ -194,7 +194,7 @@ public override IListItem[] GetItems() else if (manifest?.Parameters is { Count: > 0 }) { // Script has parameters - navigate to parameter form page - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); var formPage = new ScriptParameterFormPage( scriptPath: path, @@ -210,7 +210,7 @@ public override IListItem[] GetItems() { // No parameters, Markdown output - navigate to a page that runs // the script and renders its stdout as Markdown. - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); command = new ScriptMarkdownPage( scriptPath: path, @@ -224,7 +224,7 @@ public override IListItem[] GetItems() // No parameters, Result output - navigate to a page that runs the script // and shows its output as a single copyable result (Enter copies), the way // a calculator shows an answer. - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); command = new ScriptResultPage( scriptPath: path, diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index 436dd8f..fda60c1 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -117,6 +117,55 @@ internal static partial class PowerShellScriptParser .Replace("{Temp}", temp); } + /// + /// Expands path tokens in a [ScriptCwd(...)] value and verifies the result exists. + /// Unlike (also used for [ScriptEnv(...)] values, + /// which aren't necessarily directory paths), this is cwd-specific: a nonexistent working + /// directory would otherwise reach Process.Start and fail the whole run, so this + /// falls back to null (the process's default working directory) instead. + /// + public static string? ResolveCwd(string? cwd, string scriptPath) + { + var expanded = ExpandPathTokens(cwd, scriptPath); + if (string.IsNullOrWhiteSpace(expanded)) + { + return expanded; + } + + if (!Directory.Exists(expanded)) + { + Log.Warn($"ScriptCwd '{expanded}' does not exist for '{scriptPath}'; using the default working directory instead"); + return null; + } + + return expanded; + } + + private const int MinTimeoutMs = 1000; + private const int MaxTimeoutMs = 600_000; // 10 minutes + + /// + /// Rejects a timeout too small to be meaningful (and negative values, which would throw + /// from Process.WaitForExit) and clamps one that's unreasonably large, so a typo'd + /// [ScriptTimeout(...)] can't hang a run indefinitely or fail it instantly. + /// + private static int? ValidateTimeout(int timeoutMs) + { + if (timeoutMs < MinTimeoutMs) + { + Log.Warn($"Ignoring ScriptTimeout({timeoutMs}) — must be at least {MinTimeoutMs}ms"); + return null; + } + + if (timeoutMs > MaxTimeoutMs) + { + Log.Warn($"Clamping ScriptTimeout({timeoutMs}) to the {MaxTimeoutMs}ms maximum"); + return MaxTimeoutMs; + } + + return timeoutMs; + } + // ----- Comment-based help ----------------------------------------------------------- private sealed class HelpInfo @@ -227,7 +276,7 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) : "Are you sure you want to run this script?"; break; case "ScriptTimeout" when values.Count >= 1 && int.TryParse(values[0], out var timeout): - manifest.TimeoutMs = timeout; + manifest.TimeoutMs = ValidateTimeout(timeout); break; case "ScriptOutput" when values.Count >= 1: // The mode may carry an extension hint for File mode after a colon, From bacf71b70fa91677ae3f12aca4bc87b369c3fcb2 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 07:22:02 -0400 Subject: [PATCH 06/12] New script form improvements --- .../NewScriptWizardFormTests.cs | 115 ++++++++++ .../Forms/NewScriptWizardForm.cs | 198 ++++++++++++++++-- 2 files changed, 290 insertions(+), 23 deletions(-) create mode 100644 PaletteShellExtension.Tests/NewScriptWizardFormTests.cs diff --git a/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs b/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs new file mode 100644 index 0000000..cd0ef97 --- /dev/null +++ b/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs @@ -0,0 +1,115 @@ +using System.IO; +using PaletteShellExtension.Forms; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class NewScriptWizardFormTests +{ + [Fact] + public void SubmitForm_Create_WritesScriptWithAllOptions_AndParserReadsItBack() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + var inputs = """ + { + "name": "MyTestScript", + "description": "Does a thing", + "group": "My Group", + "icon": "🎯", + "output": "Result", + "host": "pwsh", + "timeout": "45000", + "elevate": "true", + "confirm": "Are you sure?", + "open": "false" + } + """; + + form.SubmitForm(inputs, """{"verb":"create"}"""); + + var path = Path.Combine(root, "MyTestScript.ps1"); + Assert.True(File.Exists(path)); + + var manifest = PowerShellScriptParser.TryParseManifest(path); + Assert.NotNull(manifest); + Assert.Equal("MyTestScript", manifest!.Title); + Assert.Equal("Does a thing", manifest.Description); + Assert.Equal("My Group", manifest.Group); + Assert.Equal("🎯", manifest.IconGlyph); + Assert.Equal("Result", manifest.Output); + Assert.Equal("pwsh", manifest.Host); + Assert.Equal(45000, manifest.TimeoutMs); + Assert.True(manifest.RequiresAdmin); + Assert.Equal("Are you sure?", manifest.ConfirmMessage); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_OmittedOptions_FallBackToDefaults() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + form.SubmitForm("""{"name":"Defaults","open":"false"}""", """{"verb":"create"}"""); + + var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "Defaults.ps1")); + + Assert.NotNull(manifest); + Assert.Equal("General", manifest!.Group); + Assert.Equal("None", manifest.Output); + Assert.Equal(20000, manifest.TimeoutMs); + Assert.Null(manifest.RequiresAdmin); + Assert.Null(manifest.ConfirmMessage); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_TimeoutBelowMinimum_FallsBackToDefault() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + form.SubmitForm("""{"name":"BadTimeout","timeout":"10","open":"false"}""", """{"verb":"create"}"""); + + var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "BadTimeout.ps1")); + + Assert.Equal(20000, manifest!.TimeoutMs); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_Cancel_DoesNotCreateFile() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + + form.SubmitForm("{}", """{"verb":"cancel"}"""); + + Assert.False(Directory.Exists(root) && Directory.GetFiles(root).Length > 0); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/PaletteShellExtension/Forms/NewScriptWizardForm.cs b/PaletteShellExtension/Forms/NewScriptWizardForm.cs index bbe7ecc..e166e4e 100644 --- a/PaletteShellExtension/Forms/NewScriptWizardForm.cs +++ b/PaletteShellExtension/Forms/NewScriptWizardForm.cs @@ -1,6 +1,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; using PaletteShellExtension.Classes; using System; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -27,6 +28,73 @@ public NewScriptWizardForm(string root) "placeholder": "MyScript", "value": "MyScript" }, + { + "type": "Input.Text", + "id": "description", + "label": "Description (optional)", + "placeholder": "Describe what this script does" + }, + { + "type": "Input.Text", + "id": "group", + "label": "Group", + "value": "General" + }, + { + "type": "Input.Text", + "id": "icon", + "label": "Icon (emoji or glyph)", + "value": "🧩" + }, + { + "type": "Input.ChoiceSet", + "id": "output", + "label": "Output mode", + "style": "compact", + "value": "None", + "choices": [ + { "title": "None — run silently", "value": "None" }, + { "title": "Toast — show output in a notification", "value": "Toast" }, + { "title": "Clipboard — copy output", "value": "Clipboard" }, + { "title": "Markdown — render output as Markdown", "value": "Markdown" }, + { "title": "Result — single copyable result", "value": "Result" }, + { "title": "List — searchable list of items", "value": "List" }, + { "title": "File — open output in editor", "value": "File" } + ] + }, + { + "type": "Input.ChoiceSet", + "id": "host", + "label": "Host", + "style": "compact", + "value": "pwsh", + "choices": [ + { "title": "PowerShell 7 (pwsh)", "value": "pwsh" }, + { "title": "Windows PowerShell 5.1", "value": "powershell" } + ] + }, + { + "type": "Input.Number", + "id": "timeout", + "label": "Timeout (ms)", + "value": 20000, + "min": 1000, + "max": 600000 + }, + { + "type": "Input.Toggle", + "id": "elevate", + "title": "Requires administrator rights", + "valueOn": "true", + "valueOff": "false", + "value": "false" + }, + { + "type": "Input.Text", + "id": "confirm", + "label": "Confirmation message (optional — prompts before running)", + "placeholder": "Are you sure you want to run this script?" + }, { "type": "Input.Toggle", "id": "open", @@ -61,10 +129,21 @@ public override CommandResult SubmitForm(string inputs, string data) } var rawName = formInput["name"]?.ToString()?.Trim(); + var name = string.IsNullOrWhiteSpace(rawName) ? "MyScript" : rawName; + + var options = new ScriptOptions( + Description: formInput["description"]?.ToString()?.Trim(), + Group: formInput["group"]?.ToString()?.Trim(), + Icon: formInput["icon"]?.ToString()?.Trim(), + Output: formInput["output"]?.ToString()?.Trim(), + Host: formInput["host"]?.ToString()?.Trim(), + TimeoutMs: ParseTimeout(formInput["timeout"]?.ToString()), + RequiresElevation: (formInput["elevate"]?.ToString() ?? "false").Equals("true", StringComparison.OrdinalIgnoreCase), + ConfirmMessage: formInput["confirm"]?.ToString()?.Trim()); + var open = (formInput["open"]?.ToString() ?? "true").Equals("true", StringComparison.OrdinalIgnoreCase); - var name = string.IsNullOrWhiteSpace(rawName) ? "MyScript" : rawName; - var path = CreateScript(_root, name); + var path = CreateScript(_root, name, options); if (open && path is not null) EditorLauncher.Open(path); @@ -86,7 +165,26 @@ public override CommandResult SubmitForm(string inputs, string data) } } - private static string? CreateScript(string root, string rawName) + private static int ParseTimeout(string? raw) + { + const int Default = 20000, Min = 1000, Max = 600_000; + if (!int.TryParse(raw, out var value) || value < Min) + return Default; + return Math.Min(value, Max); + } + + /// Collected wizard answers that shape the generated attribute block and body. + private readonly record struct ScriptOptions( + string? Description, + string? Group, + string? Icon, + string? Output, + string? Host, + int TimeoutMs, + bool RequiresElevation, + string? ConfirmMessage); + + private static string? CreateScript(string root, string rawName, ScriptOptions options) { Directory.CreateDirectory(root); @@ -96,7 +194,8 @@ public override CommandResult SubmitForm(string inputs, string data) var full = UniquePath(Path.Combine(root, safe)); var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - File.WriteAllText(full, DefaultHeader(Path.GetFileNameWithoutExtension(full)) + BlankBody(), utf8NoBom); + var content = BuildHeader(Path.GetFileNameWithoutExtension(full), options) + BuildParamBlock() + BuildBody(options.Output); + File.WriteAllText(full, content, utf8NoBom); return full; } private static string Sanitize(string name) @@ -120,35 +219,88 @@ private static string UniquePath(string path) if (!File.Exists(candidate)) return candidate; } } + // ---------- templates (match the sample-script format the parser reads: // comment-based help + [Script*] attributes) ---------- - private static string DefaultHeader(string name) => -$@"using module .\PaletteScriptAttributes.psm1 - -<# -.SYNOPSIS - {name} -.DESCRIPTION - Describe what this script does. -#> -[ScriptHost('pwsh')] -[ScriptGroup('General')] -[ScriptIcon('🧩')] -[ScriptTimeout(20000)] -[ScriptOutput('None')] -[CmdletBinding()] -"; + private static string EscapeSingleQuoted(string value) => value.Replace("'", "''"); + + private static string BuildHeader(string name, ScriptOptions options) + { + var sb = new StringBuilder(); + sb.Append("using module .\\PaletteScriptAttributes.psm1\n\n"); + sb.Append("<#\n.SYNOPSIS\n ").Append(name).Append('\n'); + + var description = string.IsNullOrWhiteSpace(options.Description) + ? "Describe what this script does." + : options.Description; + sb.Append(".DESCRIPTION\n ").Append(description).Append('\n'); + sb.Append("#>\n"); + + var host = string.IsNullOrWhiteSpace(options.Host) ? "pwsh" : options.Host; + sb.Append(CultureInfo.InvariantCulture, $"[ScriptHost('{host}')]\n"); + + var group = string.IsNullOrWhiteSpace(options.Group) ? "General" : options.Group; + sb.Append(CultureInfo.InvariantCulture, $"[ScriptGroup('{EscapeSingleQuoted(group)}')]\n"); + + if (!string.IsNullOrWhiteSpace(options.Icon)) + sb.Append(CultureInfo.InvariantCulture, $"[ScriptIcon('{EscapeSingleQuoted(options.Icon)}')]\n"); - private static string BlankBody() => + if (options.RequiresElevation) + sb.Append("[RequiresElevation()]\n"); + + if (!string.IsNullOrWhiteSpace(options.ConfirmMessage)) + sb.Append(CultureInfo.InvariantCulture, $"[ConfirmBeforeRun('{EscapeSingleQuoted(options.ConfirmMessage)}')]\n"); + + sb.Append(CultureInfo.InvariantCulture, $"[ScriptTimeout({options.TimeoutMs})]\n"); + + var output = string.IsNullOrWhiteSpace(options.Output) ? "None" : options.Output; + sb.Append(CultureInfo.InvariantCulture, $"[ScriptOutput('{output}')]\n"); + + sb.Append("[CmdletBinding()]\n"); + return sb.ToString(); + } + + private static string BuildParamBlock() => @"param( # Add parameters here # [Parameter(Mandatory=$true)] # [string]$Path ) -# --- Script body --- -# Write-Host ""Hello from PaletteShell!"" "; + /// A short, working body matching the chosen output mode, so the scaffold + /// demonstrates the right shape (e.g. Result mode expects a single emitted value) + /// instead of leaving every mode with the same generic placeholder. + private static string BuildBody(string? output) => (string.IsNullOrWhiteSpace(output) ? "None" : output) switch + { + "Result" => + "# Emit just the value; Result mode shows it as a single copyable result.\n" + + "[System.Guid]::NewGuid().ToString()\n", + + "List" => + "# Print newline-delimited items, or a JSON array of objects (title/subtitle/value/url/icon) for richer items.\n" + + "Get-ChildItem -Name\n", + + "Markdown" => + "# Captured stdout is rendered as Markdown on its own page.\n" + + "\"## Report`n`nSomething happened.\"\n", + + "Clipboard" => + "# Whatever stdout writes is copied to the clipboard.\n" + + "\"Copied text\"\n", + + "File" => + "# Captured stdout is written to a temp file and opened in your editor.\n" + + "Get-Process | Select-Object Name, Id, CPU | ConvertTo-Csv -NoTypeInformation\n", + + "Toast" => + "# Captured stdout is shown in a notification.\n" + + "\"Done!\"\n", + + _ => + "# --- Script body ---\n" + + "# Write-Host \"Hello from PaletteShell!\"\n" + }; } From 0af66e68781a0e19248e8d11e1af76e558ef67f4 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 07:29:46 -0400 Subject: [PATCH 07/12] improvements dealing with other languages --- .../Classes/EditorLauncher.cs | 7 +++- PaletteShellExtension/Classes/ScriptRunner.cs | 34 ++++++------------- .../Forms/NewScriptWizardForm.cs | 2 +- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/PaletteShellExtension/Classes/EditorLauncher.cs b/PaletteShellExtension/Classes/EditorLauncher.cs index 8c200c7..2f0fe77 100644 --- a/PaletteShellExtension/Classes/EditorLauncher.cs +++ b/PaletteShellExtension/Classes/EditorLauncher.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; namespace PaletteShellExtension.Classes; @@ -11,6 +12,10 @@ namespace PaletteShellExtension.Classes; /// internal static class EditorLauncher { + // Written with a BOM so arbitrary external editors (notably the notepad.exe fallback) + // reliably detect UTF-8 instead of guessing at all-non-Latin content. + private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true); + public static void Open(string path) { var editor = Environment.GetEnvironmentVariable("VISUAL") @@ -34,7 +39,7 @@ public static string OpenContent(string content, string? extension = null, strin var fileName = $"{name}-{DateTime.Now:yyyyMMdd-HHmmss}{NormalizeExtension(extension)}"; var path = Path.Combine(dir, fileName); - File.WriteAllText(path, content ?? ""); + File.WriteAllText(path, content ?? "", Utf8WithBom); Open(path); return path; } diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index 66b8f88..be3bce5 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -118,29 +118,17 @@ public static ProcessStartInfo BuildProcessStartInfo( // Pre-load the module so attributes can be resolved at parse time var scriptDir = Path.GetDirectoryName(scriptPath) ?? ""; var modulePath = Path.Combine(scriptDir, "PaletteScriptAttributes.psm1"); - if (File.Exists(modulePath)) - { - psi.ArgumentList.Add("-Command"); - // Import module, force UTF-8 console output so captured stdout isn't mangled - // (the `using` statement must come first), then dot-source the script with - // args. Always redirect the information stream (6) to stdout to capture Write-Host. - var commandString = $"using module '{modulePath}'; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . '{scriptPath}' {args} 6>&1"; - psi.ArgumentList.Add(commandString); - } - else - { - psi.ArgumentList.Add("-File"); - psi.ArgumentList.Add(scriptPath); - - // Add arguments - if (!string.IsNullOrWhiteSpace(args)) - { - foreach (var arg in args.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - psi.ArgumentList.Add(arg); - } - } - } + var usingModule = File.Exists(modulePath) ? $"using module '{modulePath}'; " : ""; + + psi.ArgumentList.Add("-Command"); + // Import the module when present (the `using` statement must come first), force + // UTF-8 console output so captured stdout isn't mangled, then dot-source the script + // with args. Always redirect the information stream (6) to stdout to capture + // Write-Host. `args` is already single-quoted per value by the caller, so it's + // interpolated into this single command string rather than re-split into + // ArgumentList entries (which would break values containing spaces). + var commandString = $"{usingModule}[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . '{scriptPath}' {args} 6>&1"; + psi.ArgumentList.Add(commandString); if (!string.IsNullOrWhiteSpace(cwd)) { diff --git a/PaletteShellExtension/Forms/NewScriptWizardForm.cs b/PaletteShellExtension/Forms/NewScriptWizardForm.cs index e166e4e..e110821 100644 --- a/PaletteShellExtension/Forms/NewScriptWizardForm.cs +++ b/PaletteShellExtension/Forms/NewScriptWizardForm.cs @@ -168,7 +168,7 @@ public override CommandResult SubmitForm(string inputs, string data) private static int ParseTimeout(string? raw) { const int Default = 20000, Min = 1000, Max = 600_000; - if (!int.TryParse(raw, out var value) || value < Min) + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) || value < Min) return Default; return Math.Min(value, Max); } From 08abdbc1f8b7290a664952916721faf3d8eda2a1 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 07:40:07 -0400 Subject: [PATCH 08/12] small housekeeping --- .gitignore | 1 - .../PaletteShellExtension.csproj | 29 +++++-------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 6c20295..bbd4c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -167,4 +167,3 @@ BenchmarkDotNet.Artifacts/ # ========================= !Directory.Build.rsp /PaletteShellExtension/bundle_mapping.txt -/PaletteShellExtension/PackageApplication.ps1 diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 8a9bef2..668560b 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -39,33 +39,18 @@ - + - - - - - - + + + + + + From 6500fc30c3b79c66027d3b6b250d8f1b67ec9773 Mon Sep 17 00:00:00 2001 From: phireforge Date: Fri, 3 Jul 2026 07:50:45 -0400 Subject: [PATCH 09/12] incrementing version --- PaletteShellExtension/Package.appxmanifest | 2 +- PaletteShellExtension/PaletteShellExtension.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PaletteShellExtension/Package.appxmanifest b/PaletteShellExtension/Package.appxmanifest index 767050d..71e062a 100644 --- a/PaletteShellExtension/Package.appxmanifest +++ b/PaletteShellExtension/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="0.0.6.0" /> PaletteShell phireForge diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 668560b..72a40fe 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -16,7 +16,7 @@ phireForge.PaletteShell phireForge - 0.0.5.0 + 0.0.6.0