From 2e37a549358cf3f94d1b98a0315ff41421cf4718 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:22:05 -0400 Subject: [PATCH 01/21] Add script attributes for version/tags/min-max app compatibility, an Open output mode, tracking for installed community/sample scripts, and a browse community scripts command that launches the Script Manager app (falling back to GitHub). --- AGENTS.md | 23 ++- .../AppVersionTests.cs | 89 +++++++++ .../InstalledCommunityScriptsTests.cs | 117 ++++++++++++ .../NewScriptWizardFormTests.cs | 2 + .../PowerShellScriptParserTests.cs | 80 +++++++++ .../SampleScriptInstallerTests.cs | 144 +++++++++++++++ .../ScriptOutputHandlerTests.cs | 19 ++ PaletteShellExtension/Classes/AppVersion.cs | 81 +++++++++ .../Classes/InstalledCommunityScripts.cs | 122 +++++++++++++ .../Classes/SampleScriptInstaller.cs | 169 ++++++++++++++++++ .../Classes/ScriptManifest.cs | 14 ++ .../Classes/ScriptOutputHandler.cs | 37 ++++ .../Commands/CopyValueCommand.cs | 4 +- .../Commands/IncompatibleScriptCommand.cs | 20 +++ .../Commands/LaunchCommunityStoreCommand.cs | 45 +++++ .../Forms/NewScriptWizardForm.cs | 11 ++ .../Forms/ScriptParameterForm.cs | 15 +- PaletteShellExtension/Package.appxmanifest | 2 +- .../Pages/PaletteShellExtensionPage.cs | 136 ++++++++++---- .../PaletteScriptAttributes.psm1 | 32 +++- .../PaletteShellExtension.csproj | 2 +- .../PowerShellScriptParser.cs | 144 +++++++++------ .../SampleScripts/Open-TempFolder.ps1 | 19 ++ README.md | 51 ++++-- 24 files changed, 1261 insertions(+), 117 deletions(-) create mode 100644 PaletteShellExtension.Tests/AppVersionTests.cs create mode 100644 PaletteShellExtension.Tests/InstalledCommunityScriptsTests.cs create mode 100644 PaletteShellExtension.Tests/SampleScriptInstallerTests.cs create mode 100644 PaletteShellExtension.Tests/ScriptOutputHandlerTests.cs create mode 100644 PaletteShellExtension/Classes/AppVersion.cs create mode 100644 PaletteShellExtension/Classes/InstalledCommunityScripts.cs create mode 100644 PaletteShellExtension/Classes/SampleScriptInstaller.cs create mode 100644 PaletteShellExtension/Commands/IncompatibleScriptCommand.cs create mode 100644 PaletteShellExtension/Commands/LaunchCommunityStoreCommand.cs create mode 100644 PaletteShellExtension/SampleScripts/Open-TempFolder.ps1 diff --git a/AGENTS.md b/AGENTS.md index 5bbf125..afffa4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,11 @@ Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything e |-----------|---------| | `[ScriptHost('pwsh')]` | Host to run under: `'pwsh'` (PowerShell 7, default) or `'powershell'` (Windows PowerShell 5.1) | | `[ScriptCwd('{ScriptDir}')]` | Working directory (supports path tokens, below) | -| `[ScriptGroup('Category')]` | Group name, shown as a tag and used for grouping | +| `[ScriptGroup('Category')]` | Group name used by tooling such as the Script Manager catalog browser | +| `[ScriptTags('foo,bar,baz')]` | Comma-delimited free-form tags used by tooling such as the Script Manager catalog browser | +| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) โ€” lets tools detect when a newer copy is available | +| `[RequiresPaletteShellMinimum('1.2.0')]` | Minimum PaletteShell app version required. If the installed app is older, the row shows "Requires an update" instead of running the script. Defaults to `0.0.6` (the last release before this attribute existed) when omitted | +| `[RequiresPaletteShellMaximum('2.0.0')]` | Maximum PaletteShell app version this script still works on. If the installed app is newer, the row shows "Requires an update" instead of running the script. Optional โ€” use only if your script depends on behavior later removed or changed | | `[ScriptIcon('๐Ÿš€')]` | Emoji or glyph shown on the row | | `[ScriptOutput('None')]` | How stdout is handled (see [Output modes](#output-modes)) | | `[ScriptTimeout(30000)]` | Timeout in **milliseconds**; also forces wait-and-capture | @@ -94,6 +98,7 @@ Set with `[ScriptOutput('')]`. Default is `None`. | `Result` | Wait, show stdout as a single copyable result (Enter copies; a **Run again** command regenerates). Print just the value; good for generators (GUID, password, token). | | `File` | Write stdout to a temp file and open it in the user's editor. Add an extension hint after a colon: `File:csv`, `File:json`, etc. Best for large/structured output. | | `List` | Parse stdout into a searchable, pickable list โ€” turns the script into a search/pick provider (see below). | +| `Open` | Open the first non-empty stdout line as a URL, file, or folder path. | Anything other than `None` (or any `[ScriptTimeout]`) makes PaletteShell **wait** for the process, up to the timeout (30s default) before killing it. So: emit results on **stdout** (`Write-Host` / @@ -209,6 +214,18 @@ param([string]$Query) ) | ConvertTo-Json -AsArray -Compress ``` +## Open output + +`[ScriptOutput('Open')]` runs the script and opens the first non-empty stdout line as a URL, +file, or folder path. Emit only the target on stdout. + +```powershell +[ScriptOutput('Open')] +param() + +[System.IO.Path]::GetTempPath() +``` + ## Helper functions (from the module) Because line 1 imports `PaletteScriptAttributes.psm1`, these are available: @@ -233,6 +250,6 @@ transform, write back with `Set-ClipboardText`, and `Write-Host` a short status - [ ] Saved as UTF-8; icon is a single emoji/glyph. - [ ] Exits non-zero on failure so the palette reports it. -See the bundled sample scripts (`Text-Transform.ps1`, `Git-Branches.ps1`, `System-Report.ps1`, -`Clear-TempFiles.ps1`, โ€ฆ) for working examples of each pattern, and the community library at +See the bundled sample scripts (`Text-Transform.ps1`, `Git-Branches.ps1`, `Open-TempFolder.ps1`, +`System-Report.ps1`, `Clear-TempFiles.ps1`, โ€ฆ) for working examples of each pattern, and the community library at . diff --git a/PaletteShellExtension.Tests/AppVersionTests.cs b/PaletteShellExtension.Tests/AppVersionTests.cs new file mode 100644 index 0000000..4608c17 --- /dev/null +++ b/PaletteShellExtension.Tests/AppVersionTests.cs @@ -0,0 +1,89 @@ +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class AppVersionTests +{ + [Fact] + public void IsCompatible_WhenMinVersionNull_ReturnsTrue() + { + Assert.True(AppVersion.IsCompatible(null, out var required)); + Assert.Null(required); + } + + [Fact] + public void IsCompatible_WhenMinVersionUnparseable_FailsOpen() + { + Assert.True(AppVersion.IsCompatible("not-a-version", out var required)); + Assert.Null(required); + } + + [Fact] + public void IsCompatible_WhenMinVersionAtOrBelowCurrent_ReturnsTrue() + { + Assert.True(AppVersion.IsCompatible(AppVersion.Current.ToString(), out _)); + Assert.True(AppVersion.IsCompatible("0.0.0.0", out _)); + } + + [Fact] + public void IsCompatible_WhenMinVersionAboveCurrent_ReturnsFalse() + { + var future = new System.Version(AppVersion.Current.Major + 1, 0, 0, 0); + + var result = AppVersion.IsCompatible(future.ToString(), out var required); + + Assert.False(result); + Assert.Equal(future, required); + } + + [Fact] + public void IsCompatible_WhenMaxVersionNullOrUnset_ReturnsTrue() + { + Assert.True(AppVersion.IsCompatible(null, null, out var required, out var tooNew)); + Assert.Null(required); + Assert.False(tooNew); + } + + [Fact] + public void IsCompatible_WhenMaxVersionUnparseable_FailsOpen() + { + Assert.True(AppVersion.IsCompatible(null, "not-a-version", out var required, out var tooNew)); + Assert.Null(required); + Assert.False(tooNew); + } + + [Fact] + public void IsCompatible_WhenMaxVersionAtOrAboveCurrent_ReturnsTrue() + { + Assert.True(AppVersion.IsCompatible(null, AppVersion.Current.ToString(), out _, out _)); + + var future = new System.Version(AppVersion.Current.Major + 1, 0, 0, 0); + Assert.True(AppVersion.IsCompatible(null, future.ToString(), out _, out _)); + } + + [Fact] + public void IsCompatible_WhenMaxVersionBelowCurrent_ReturnsFalseAndFlagsTooNew() + { + var past = new System.Version(0, 0, 0, 1); + + var result = AppVersion.IsCompatible(null, past.ToString(), out var required, out var tooNew); + + Assert.False(result); + Assert.Equal(past, required); + Assert.True(tooNew); + } + + [Fact] + public void IsCompatible_WhenBothViolated_MinimumWins() + { + var future = new System.Version(AppVersion.Current.Major + 1, 0, 0, 0); + var past = new System.Version(0, 0, 0, 1); + + var result = AppVersion.IsCompatible(future.ToString(), past.ToString(), out var required, out var tooNew); + + Assert.False(result); + Assert.Equal(future, required); + Assert.False(tooNew); + } +} diff --git a/PaletteShellExtension.Tests/InstalledCommunityScriptsTests.cs b/PaletteShellExtension.Tests/InstalledCommunityScriptsTests.cs new file mode 100644 index 0000000..69821f0 --- /dev/null +++ b/PaletteShellExtension.Tests/InstalledCommunityScriptsTests.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class InstalledCommunityScriptsTests +{ + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), $"pstest-installed-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return root; + } + + [Fact] + public void Record_ThenTryGetInstalled_RoundTrips() + { + var root = CreateTempRoot(); + try + { + var store = new InstalledCommunityScripts(root); + var localPath = Path.Combine(root, "Foo.ps1"); + + Assert.False(store.TryGetInstalled(localPath, out _)); + + store.Record(localPath, "Clipboard/Foo.ps1", "sha1"); + + Assert.True(store.TryGetInstalled(localPath, out var record)); + Assert.Equal("Clipboard/Foo.ps1", record!.SourcePath); + Assert.Equal("sha1", record.Sha); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Record_PersistsAcrossInstances() + { + var root = CreateTempRoot(); + try + { + var localPath = Path.Combine(root, "Foo.ps1"); + new InstalledCommunityScripts(root).Record(localPath, "Clipboard/Foo.ps1", "sha1"); + + var reloaded = new InstalledCommunityScripts(root); + + Assert.True(reloaded.TryGetInstalled(localPath, out var record)); + Assert.Equal("sha1", record!.Sha); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Record_WithVersion_RoundTripsAcrossInstances() + { + var root = CreateTempRoot(); + try + { + var localPath = Path.Combine(root, "Foo.ps1"); + new InstalledCommunityScripts(root).Record(localPath, "Clipboard/Foo.ps1", "sha1", "1.2.0"); + + var reloaded = new InstalledCommunityScripts(root); + + Assert.True(reloaded.TryGetInstalled(localPath, out var record)); + Assert.Equal("1.2.0", record!.Version); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Remove_ClearsTheRecord() + { + var root = CreateTempRoot(); + try + { + var store = new InstalledCommunityScripts(root); + var localPath = Path.Combine(root, "Foo.ps1"); + store.Record(localPath, "Clipboard/Foo.ps1", "sha1"); + + store.Remove(localPath); + + Assert.False(store.TryGetInstalled(localPath, out _)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CorruptStoreFile_LoadsAsEmpty() + { + var root = CreateTempRoot(); + try + { + File.WriteAllText(Path.Combine(root, "community-installed.json"), "not json"); + + var store = new InstalledCommunityScripts(root); + + Assert.False(store.TryGetInstalled(Path.Combine(root, "Foo.ps1"), out _)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs b/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs index 2343763..f7dce0e 100644 --- a/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs +++ b/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs @@ -1,4 +1,5 @@ using System.IO; +using PaletteShellExtension.Classes; using PaletteShellExtension.Forms; using Xunit; @@ -44,6 +45,7 @@ public void SubmitForm_Create_WritesScriptWithAllOptions_AndParserReadsItBack() Assert.Equal(45000, manifest.TimeoutMs); Assert.True(manifest.RequiresAdmin); Assert.Equal("Are you sure?", manifest.ConfirmMessage); + Assert.Equal(AppVersion.Current.ToString(), manifest.MinVersion); } finally { diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs index 1ef0f1d..ad833e6 100644 --- a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -337,6 +337,86 @@ public void ScriptGroup_IsParsed() Assert.Equal("Utilities", manifest!.Group); } + [Fact] + public void ScriptTags_IsParsedAsCommaDelimitedList() + { + using var file = new TestScriptFile("[ScriptTags('network, dns,admin')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(["network", "dns", "admin"], manifest!.Tags); + } + + [Fact] + public void ScriptTags_Absent_YieldsEmptyList() + { + using var file = new TestScriptFile("param()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Empty(manifest!.Tags); + } + + [Fact] + public void ScriptVersion_IsParsed() + { + using var file = new TestScriptFile("[ScriptVersion('1.2.0')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("1.2.0", manifest!.Version); + } + + [Fact] + public void ScriptVersion_WhenOmitted_DefaultsTo1_0_0() + { + using var file = new TestScriptFile("param()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("1.0.0", manifest!.Version); + } + + [Fact] + public void RequiresPaletteShellMinimum_IsParsed() + { + using var file = new TestScriptFile("[RequiresPaletteShellMinimum('1.2.0')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("1.2.0", manifest!.MinVersion); + } + + [Fact] + public void RequiresPaletteShellMinimum_WhenOmitted_DefaultsTo0_0_6() + { + using var file = new TestScriptFile("param()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("0.0.6", manifest!.MinVersion); + } + + [Fact] + public void RequiresPaletteShellMaximum_IsParsed() + { + using var file = new TestScriptFile("[RequiresPaletteShellMaximum('2.0.0')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("2.0.0", manifest!.MaxVersion); + } + + [Fact] + public void RequiresPaletteShellMaximum_WhenOmitted_StaysNull() + { + using var file = new TestScriptFile("param()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.MaxVersion); + } + [Fact] public void ScriptEnv_MultipleAttributes_AllCaptured() { diff --git a/PaletteShellExtension.Tests/SampleScriptInstallerTests.cs b/PaletteShellExtension.Tests/SampleScriptInstallerTests.cs new file mode 100644 index 0000000..884194e --- /dev/null +++ b/PaletteShellExtension.Tests/SampleScriptInstallerTests.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class InstalledSampleScriptsTests +{ + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), $"pstest-samples-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return root; + } + + [Fact] + public void Record_ThenTryGet_RoundTrips() + { + var root = CreateTempRoot(); + try + { + var store = new InstalledSampleScripts(root); + + Assert.False(store.TryGet("Foo.ps1", out _)); + + store.Record("Foo.ps1", "1.0.0", "hash1"); + + Assert.True(store.TryGet("Foo.ps1", out var record)); + Assert.Equal("1.0.0", record!.Version); + Assert.Equal("hash1", record.ContentHash); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Record_PersistsAcrossInstances() + { + var root = CreateTempRoot(); + try + { + new InstalledSampleScripts(root).Record("Foo.ps1", "1.0.0", "hash1"); + + var reloaded = new InstalledSampleScripts(root); + + Assert.True(reloaded.TryGet("Foo.ps1", out var record)); + Assert.Equal("1.0.0", record!.Version); + Assert.Equal("hash1", record.ContentHash); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CorruptStoreFile_LoadsAsEmpty() + { + var root = CreateTempRoot(); + try + { + File.WriteAllText(Path.Combine(root, "sample-scripts.json"), "not json"); + + var store = new InstalledSampleScripts(root); + + Assert.False(store.TryGet("Foo.ps1", out _)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +} + +public class SampleScriptSyncTests +{ + [Fact] + public void ShouldOverwrite_FreshInstall_ReturnsTrue() + { + Assert.True(SampleScriptSync.ShouldOverwrite(targetExists: false, record: null, onDiskHash: null, shippedVersion: "1.0.0")); + } + + [Fact] + public void ShouldOverwrite_UntrackedExistingFile_ReturnsFalse() + { + Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record: null, onDiskHash: "somehash", shippedVersion: "2.0.0")); + } + + [Fact] + public void ShouldOverwrite_PreviouslyInstalledNowMissing_ReturnsFalse() + { + // The file was installed before (we have a record) but is gone now - the user (or the + // Script Manager's "Remove") deleted it on purpose. Don't fight that by recreating it. + var record = new InstalledSampleScript("1.0.0", "originalHash"); + + Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: false, record, onDiskHash: null, shippedVersion: "1.1.0")); + } + + [Fact] + public void ShouldOverwrite_UserModifiedFile_ReturnsFalse() + { + var record = new InstalledSampleScript("1.0.0", "originalHash"); + + Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "editedHash", shippedVersion: "2.0.0")); + } + + [Fact] + public void ShouldOverwrite_UnmodifiedWithNewerVersion_ReturnsTrue() + { + var record = new InstalledSampleScript("1.0.0", "originalHash"); + + Assert.True(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "originalHash", shippedVersion: "1.1.0")); + } + + [Fact] + public void ShouldOverwrite_UnmodifiedWithSameVersion_ReturnsFalse() + { + var record = new InstalledSampleScript("1.0.0", "originalHash"); + + Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "originalHash", shippedVersion: "1.0.0")); + } + + [Fact] + public void ShouldOverwrite_UnmodifiedWithOlderVersion_ReturnsFalse() + { + var record = new InstalledSampleScript("2.0.0", "originalHash"); + + Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "originalHash", shippedVersion: "1.0.0")); + } + + [Theory] + [InlineData("1.0.0", "1.0.0", false)] + [InlineData("1.1.0", "1.0.0", true)] + [InlineData("1.0.0", "1.1.0", false)] + [InlineData(null, "1.0.0", false)] + [InlineData("1.0.0", null, true)] + public void IsNewerVersion_ComparesCorrectly(string? candidate, string? baseline, bool expected) + { + Assert.Equal(expected, SampleScriptSync.IsNewerVersion(candidate, baseline)); + } +} diff --git a/PaletteShellExtension.Tests/ScriptOutputHandlerTests.cs b/PaletteShellExtension.Tests/ScriptOutputHandlerTests.cs new file mode 100644 index 0000000..721a9ab --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptOutputHandlerTests.cs @@ -0,0 +1,19 @@ +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptOutputHandlerTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("https://example.com", "https://example.com")] + [InlineData("\r\nC:\\Temp\r\nignored", "C:\\Temp")] + [InlineData("\"C:\\Path With Spaces\"", "C:\\Path With Spaces")] + public void GetOpenTarget_ReturnsFirstNonEmptyLine(string? output, string? expected) + { + Assert.Equal(expected, ScriptOutputHandler.GetOpenTarget(output)); + } +} diff --git a/PaletteShellExtension/Classes/AppVersion.cs b/PaletteShellExtension/Classes/AppVersion.cs new file mode 100644 index 0000000..ee20cda --- /dev/null +++ b/PaletteShellExtension/Classes/AppVersion.cs @@ -0,0 +1,81 @@ +using System; +using System.Reflection; +using Windows.ApplicationModel; + +namespace PaletteShellExtension.Classes; + +/// +/// Resolves the running PaletteShell version, and checks a script's +/// [RequiresPaletteShellMinimum(...)] / [RequiresPaletteShellMaximum(...)] against +/// it so scripts written for a newer app don't run (or fail confusingly) on an older one, and so +/// scripts that depend on since-removed behavior can rule out running on a too-new one. +/// +internal static class AppVersion +{ + /// The installed app's version. Read from the MSIX package identity (matches + /// Package.appxmanifest) with a fallback to the assembly version for contexts where + /// there's no package identity, e.g. unit tests. + public static Version Current { get; } = ResolveCurrent(); + + private static Version ResolveCurrent() + { + try + { + var v = Package.Current.Id.Version; + return new Version(v.Major, v.Minor, v.Build, v.Revision); + } + catch (Exception) + { + return typeof(AppVersion).Assembly.GetName().Version ?? new Version(0, 0, 0, 0); + } + } + + /// + /// True when is unset, unparseable (fails open rather than + /// hiding a script over a typo'd attribute), or no greater than . + /// + public static bool IsCompatible(string? minVersion, out Version? required) => + IsCompatible(minVersion, null, out required, out _); + + /// + /// Same as the single-argument overload, but also checks : false + /// (with set) when is strictly greater than + /// it. An unset or unparseable never blocks - same fail-open + /// stance as . When both bounds are violated, the minimum wins + /// (a script can't be both too old and too new for a single install). + /// + public static bool IsCompatible(string? minVersion, string? maxVersion, out Version? required, out bool tooNew) + { + tooNew = false; + + if (!string.IsNullOrWhiteSpace(minVersion)) + { + if (!Version.TryParse(minVersion, out var min)) + { + Log.Warn($"Ignoring invalid RequiresPaletteShellMinimum '{minVersion}' โ€” expected a dotted version like '1.2.0'"); + } + else if (Current < min) + { + required = min; + return false; + } + } + + if (!string.IsNullOrWhiteSpace(maxVersion)) + { + if (!Version.TryParse(maxVersion, out var max)) + { + Log.Warn($"Ignoring invalid RequiresPaletteShellMaximum '{maxVersion}' โ€” expected a dotted version like '1.2.0'"); + } + else if (Current > max) + { + required = max; + tooNew = true; + return false; + } + } + + required = null; + return true; + } +} diff --git a/PaletteShellExtension/Classes/InstalledCommunityScripts.cs b/PaletteShellExtension/Classes/InstalledCommunityScripts.cs new file mode 100644 index 0000000..6b356b3 --- /dev/null +++ b/PaletteShellExtension/Classes/InstalledCommunityScripts.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PaletteShellExtension.Classes; + +/// Where a locally installed script came from in the community catalog, so an +/// update check can compare its recorded sha against the catalog's current one. Version is +/// informational only (display), not used for update detection. +internal sealed record InstalledCommunityScript(string SourcePath, string Sha, string? Version = null); + +/// +/// Tracks which local scripts were installed from the community catalog, so the palette can +/// show provenance, detect when a newer version is available (sha mismatch), and avoid +/// clobbering a same-named script the user wrote themselves. State is a small JSON file +/// (community-installed.json) alongside pinned.txt - same best-effort, +/// never-throw philosophy as : a locked or corrupt store never +/// breaks the list, it just starts empty for the session. +/// +internal sealed class InstalledCommunityScripts +{ + private const string StoreFileName = "community-installed.json"; + + private readonly string _rootDirectory; + private readonly string _storePath; + private readonly Dictionary _installed; + + public InstalledCommunityScripts(string rootDirectory) + { + _rootDirectory = rootDirectory; + _storePath = Path.Combine(rootDirectory, StoreFileName); + _installed = Load(_storePath); + } + + public bool TryGetInstalled(string localPath, out InstalledCommunityScript? record) => + _installed.TryGetValue(KeyFor(localPath), out record); + + /// Records (or updates) the install and persists the change. + public void Record(string localPath, string sourcePath, string? sha, string? version = null) + { + _installed[KeyFor(localPath)] = new InstalledCommunityScript(sourcePath, sha ?? "", version); + Save(); + } + + public void Remove(string localPath) + { + if (_installed.Remove(KeyFor(localPath))) + { + Save(); + } + } + + // Path relative to the scripts root, normalized to forward slashes - same convention as + // PinnedScripts so a rename/move simply orphans the record rather than breaking anything. + private string KeyFor(string path) => + Path.GetRelativePath(_rootDirectory, path) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + + private static Dictionary Load(string storePath) + { + try + { + if (File.Exists(storePath)) + { + var root = JsonNode.Parse(File.ReadAllText(storePath))?.AsObject(); + if (root is not null) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in root) + { + var obj = value?.AsObject(); + var source = obj?["sourcePath"]?.ToString(); + if (source is not null) + { + map[key] = new InstalledCommunityScript(source, obj?["sha"]?.ToString() ?? "", obj?["version"]?.ToString()); + } + } + + return map; + } + } + } + catch (Exception) + { + // Missing or corrupt store - start with nothing tracked rather than failing the list. + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private void Save() + { + try + { + var root = new JsonObject(); + foreach (var (key, value) in _installed.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + var entry = new JsonObject + { + ["sourcePath"] = value.SourcePath, + ["sha"] = value.Sha, + }; + if (value.Version is not null) + { + entry["version"] = value.Version; + } + + root[key] = entry; + } + + File.WriteAllText(_storePath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception) + { + // Best-effort: an in-memory record still takes effect this session even if it can't persist. + } + } +} diff --git a/PaletteShellExtension/Classes/SampleScriptInstaller.cs b/PaletteShellExtension/Classes/SampleScriptInstaller.cs new file mode 100644 index 0000000..7cc3886 --- /dev/null +++ b/PaletteShellExtension/Classes/SampleScriptInstaller.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PaletteShellExtension.Classes; + +/// Version and content hash of a bundled sample script as of the last time this +/// process wrote it to the scripts folder, so a later run can tell whether the shipped copy +/// has moved on and whether the user has since edited their copy. +internal sealed record InstalledSampleScript(string? Version, string ContentHash); + +/// +/// Tracks the version/hash of each bundled sample script this process has written into the +/// scripts folder. State is a small JSON file (sample-scripts.json) alongside +/// pinned.txt and community-installed.json - same best-effort, never-throw +/// philosophy as those: a locked or corrupt store never breaks startup, it just starts empty +/// for the session (which just means samples fall back to "don't overwrite" until it can +/// write again). +/// +internal sealed class InstalledSampleScripts +{ + private const string StoreFileName = "sample-scripts.json"; + + private readonly string _storePath; + private readonly Dictionary _installed; + + public InstalledSampleScripts(string rootDirectory) + { + _storePath = Path.Combine(rootDirectory, StoreFileName); + _installed = Load(_storePath); + } + + public bool TryGet(string fileName, out InstalledSampleScript? record) => + _installed.TryGetValue(fileName, out record); + + /// Records (or updates) the sample's installed version/hash and persists the change. + public void Record(string fileName, string? version, string contentHash) + { + Record(fileName, version, contentHash, persist: true); + } + + internal void Record(string fileName, string? version, string contentHash, bool persist) + { + _installed[fileName] = new InstalledSampleScript(version, contentHash); + if (persist) + { + Save(); + } + } + + internal void Save() => SaveCore(); + + private static Dictionary Load(string storePath) + { + try + { + if (File.Exists(storePath)) + { + var root = JsonNode.Parse(File.ReadAllText(storePath))?.AsObject(); + if (root is not null) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in root) + { + var obj = value?.AsObject(); + var hash = obj?["hash"]?.ToString(); + if (hash is not null) + { + map[key] = new InstalledSampleScript(obj?["version"]?.ToString(), hash); + } + } + + return map; + } + } + } + catch (Exception) + { + // Missing or corrupt store - start with nothing tracked rather than failing startup. + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private void SaveCore() + { + try + { + var root = new JsonObject(); + foreach (var (key, value) in _installed.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + var entry = new JsonObject { ["hash"] = value.ContentHash }; + if (value.Version is not null) + { + entry["version"] = value.Version; + } + + root[key] = entry; + } + + File.WriteAllText(_storePath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception) + { + // Best-effort: an in-memory record still takes effect this session even if it can't persist. + } + } +} + +/// +/// Decides whether a bundled sample script should (re)write its target file: yes for a fresh +/// install (no target, never tracked), no if the target is missing but we have a record for it +/// (the user - or another tool acting on the same folder, e.g. the Script Manager's "Remove" - +/// deleted it on purpose; don't fight that by recreating it every time this reloads), no for a +/// pre-existing file we've never tracked (don't clobber something we don't know the history of), +/// no if the on-disk copy no longer matches what we last installed (the user edited it - never +/// overwrite a user's changes), and otherwise only when the shipped version is strictly newer +/// than what's recorded. +/// +internal static class SampleScriptSync +{ + public static string ComputeHash(string content) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); + + public static bool ShouldOverwrite(bool targetExists, InstalledSampleScript? record, string? onDiskHash, string? shippedVersion) + { + if (!targetExists) + { + return record is null; + } + + if (record is null) + { + return false; + } + + if (!string.Equals(onDiskHash, record.ContentHash, StringComparison.Ordinal)) + { + return false; + } + + return IsNewerVersion(shippedVersion, record.Version); + } + + public static bool IsNewerVersion(string? candidate, string? baseline) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(baseline)) + { + return true; + } + + if (Version.TryParse(candidate, out var c) && Version.TryParse(baseline, out var b)) + { + return c > b; + } + + return string.CompareOrdinal(candidate, baseline) > 0; + } +} diff --git a/PaletteShellExtension/Classes/ScriptManifest.cs b/PaletteShellExtension/Classes/ScriptManifest.cs index f20b4f6..e9827b4 100644 --- a/PaletteShellExtension/Classes/ScriptManifest.cs +++ b/PaletteShellExtension/Classes/ScriptManifest.cs @@ -10,7 +10,21 @@ public sealed class ScriptManifest public List Parameters { get; set; } = []; public string? Group { get; set; } + public List Tags { get; set; } = []; public string? IconGlyph { get; set; } + public string? Version { get; set; } + + // Minimum PaletteShell app version (SemVer) required to run this script. A script that + // omits [RequiresPaletteShellMinimum(...)] defaults to "0.0.6" (see PowerShellScriptParser) + // rather than staying null, so this is only null when parsing raw content that skips that + // default (e.g. via ParseScriptAttributes directly in a test). + public string? MinVersion { get; set; } + + // Maximum PaletteShell app version (SemVer) this script still works on, from + // [RequiresPaletteShellMaximum(...)]. Unlike MinVersion, unset means "no ceiling" - most + // scripts don't rely on behavior later removed, so null (rather than a defaulted baseline) + // is the correct default here. + public string? MaxVersion { get; set; } public bool? RequiresAdmin { get; set; } diff --git a/PaletteShellExtension/Classes/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs index b252ed2..8e1cabf 100644 --- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs +++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs @@ -1,5 +1,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; using System; +using System.Diagnostics; +using System.Linq; namespace PaletteShellExtension.Classes; @@ -22,6 +24,27 @@ public static CommandResult ToResult(string? mode, string? output, string? fileE } return CommandResult.ShowToast("Script completed"); + // Treat stdout as a URL, file, or folder and let Windows open it with the + // registered default app. The first non-empty line is the target so scripts can + // use Write-Output without worrying about a trailing newline. + case "open": + var target = GetOpenTarget(output); + if (target is null) + { + return CommandResult.ShowToast("Script completed without an open target"); + } + + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + return CommandResult.ShowToast("Opened script output"); + } + catch (Exception ex) + { + Log.Warn($"Failed to open script output target '{target}': {ex.Message}"); + return CommandResult.ShowToast($"Couldn't open script output: {ex.Message}"); + } + // Write stdout to a temp file and open it in the user's editor. Useful for // output that's too large or structured to be readable in a toast. case "file": @@ -44,6 +67,20 @@ public static CommandResult ToResult(string? mode, string? output, string? fileE } } + internal static string? GetOpenTarget(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + return output + .Replace("\r\n", "\n") + .Split('\n') + .Select(line => line.Trim().Trim('"')) + .FirstOrDefault(line => line.Length > 0); + } + private static void TrySetClipboard(string text) { try diff --git a/PaletteShellExtension/Commands/CopyValueCommand.cs b/PaletteShellExtension/Commands/CopyValueCommand.cs index 88adca1..fd4d12f 100644 --- a/PaletteShellExtension/Commands/CopyValueCommand.cs +++ b/PaletteShellExtension/Commands/CopyValueCommand.cs @@ -7,9 +7,9 @@ namespace PaletteShellExtension.Commands; /// Copies a fixed string to the clipboard. Used by List-mode result items so picking /// an item (a line of stdout / a parsed object) copies its value. /// -internal sealed partial class CopyValueCommand(string text) : InvokableCommand +internal sealed partial class CopyValueCommand(string text, string name = "Copy") : InvokableCommand { - public override string Name => "Copy"; + public override string Name => name; public override IconInfo Icon => new("๎ฃˆ"); // Copy public override CommandResult Invoke() diff --git a/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs b/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs new file mode 100644 index 0000000..7432a67 --- /dev/null +++ b/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs @@ -0,0 +1,20 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PaletteShellExtension.Commands; + +/// +/// Stands in for a script's normal command when its [RequiresPaletteShellMinimum(...)] or +/// [RequiresPaletteShellMaximum(...)] rules out the running PaletteShell, so selecting the +/// row explains why instead of running a script that may rely on host features/behavior that +/// don't exist yet (or were since removed). +/// +internal sealed partial class IncompatibleScriptCommand(string requiredVersion, string installedVersion, bool tooNew = false) : InvokableCommand +{ + public override string Name => "Requires an update"; + public override IconInfo Icon => new(""); // Warning + + public override CommandResult Invoke() => + CommandResult.ShowToast(tooNew + ? $"This script requires PaletteShell v{requiredVersion} or earlier (you have v{installedVersion})" + : $"This script requires PaletteShell v{requiredVersion} or later (you have v{installedVersion})"); +} diff --git a/PaletteShellExtension/Commands/LaunchCommunityStoreCommand.cs b/PaletteShellExtension/Commands/LaunchCommunityStoreCommand.cs new file mode 100644 index 0000000..1b869ec --- /dev/null +++ b/PaletteShellExtension/Commands/LaunchCommunityStoreCommand.cs @@ -0,0 +1,45 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using System; +using System.Diagnostics; + +namespace PaletteShellExtension.Commands; + +/// +/// Opens the companion "PaletteShell Script Store" app (a separate, Store-distributed WinUI 3 +/// app that owns browsing/installing/updating community scripts) via its registered protocol, +/// so this extension doesn't need to know where - or whether - it's installed. Falls back to +/// the community scripts GitHub repo if the protocol has no handler, i.e. the app isn't installed yet - same +/// best-effort, never-throw philosophy as the rest of this codebase (see +/// 's error handling). +/// +internal sealed partial class LaunchCommunityStoreCommand : InvokableCommand +{ + private const string CommunityScriptsRepositoryUrl = "https://github.com/paletteshell/PaletteShellScripts"; + private const string ProtocolUrl = "palette-script-manager:"; + + public override string Name => "Open"; + public override IconInfo Icon => new("โ˜"); + + public override CommandResult Invoke() + { + try + { + Process.Start(new ProcessStartInfo(ProtocolUrl) { UseShellExecute = true }); + return CommandResult.Dismiss(); + } + catch (Exception) + { + // No handler registered for the protocol - the companion app isn't installed. + // Send the user to the community repo instead of failing silently. + try + { + Process.Start(new ProcessStartInfo(CommunityScriptsRepositoryUrl) { UseShellExecute = true }); + return CommandResult.ShowToast("PaletteShell Script Store isn't installed - opening the community scripts repo."); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Couldn't open the Script Store or community scripts repo: {ex.Message}"); + } + } + } +} diff --git a/PaletteShellExtension/Forms/NewScriptWizardForm.cs b/PaletteShellExtension/Forms/NewScriptWizardForm.cs index 9778957..f49e1e7 100644 --- a/PaletteShellExtension/Forms/NewScriptWizardForm.cs +++ b/PaletteShellExtension/Forms/NewScriptWizardForm.cs @@ -59,6 +59,7 @@ public NewScriptWizardForm(string root) { "title": "Markdown โ€” render output as Markdown", "value": "Markdown" }, { "title": "Result โ€” single copyable result", "value": "Result" }, { "title": "List โ€” searchable list of items", "value": "List" }, + { "title": "Open โ€” open a URL, file, or folder", "value": "Open" }, { "title": "File โ€” open output in editor", "value": "File" } ] }, @@ -260,6 +261,12 @@ private static string BuildHeader(string name, ScriptOptions options) var group = string.IsNullOrWhiteSpace(options.Group) ? "General" : options.Group; sb.Append(CultureInfo.InvariantCulture, $"[ScriptGroup('{EscapeSingleQuoted(group)}')]\n"); + sb.Append("[ScriptVersion('1.0.0')]\n"); + + // Stamps the app version the script was scaffolded against, so a copy of this script + // taken to an older PaletteShell install shows "Requires an update" instead of running + // against attributes/behavior it doesn't recognize yet. + sb.Append(CultureInfo.InvariantCulture, $"[RequiresPaletteShellMinimum('{AppVersion.Current}')]\n"); if (!string.IsNullOrWhiteSpace(options.Icon)) sb.Append(CultureInfo.InvariantCulture, $"[ScriptIcon('{EscapeSingleQuoted(options.Icon)}')]\n"); @@ -304,6 +311,10 @@ private static string BuildParamBlock() => "# Print newline-delimited items, or a JSON array of objects (title/subtitle/value/url/icon) for richer items.\n" + "Get-ChildItem -Name\n", + "Open" => + "# Emit a URL, file path, or folder path; Open mode launches the first non-empty line.\n" + + "[System.IO.Path]::GetTempPath()\n", + "Markdown" => "# Captured stdout is rendered as Markdown on its own page.\n" + "\"## Report`n`nSomething happened.\"\n", diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index b9a2b24..6e2f916 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -142,7 +142,7 @@ private CommandResult StartMarkdownRun(string argsLine) requiresAdmin: false, timeoutMs: timeout); - body = FormatMarkdownResult(result); + body = FormatInPageResult(result); } catch (Exception ex) { @@ -162,9 +162,9 @@ private CommandResult StartMarkdownRun(string argsLine) return CommandResult.KeepOpen(); } - /// Turns a run result into the Markdown body to render, mirroring the no-parameter - /// Markdown page: failures and empty output become a short note rather than a blank panel. - private static string FormatMarkdownResult(ScriptRunner.ScriptResult? result) + /// Turns a run result into the Markdown body to render: failures and empty output + /// become a short note rather than a blank panel. + private static string FormatInPageResult(ScriptRunner.ScriptResult? result) { if (result is null) return "_Failed to start script._"; @@ -180,9 +180,10 @@ private static string FormatMarkdownResult(ScriptRunner.ScriptResult? result) : $"**Script failed with exit code {result.ExitCode}.**\n\n```\n{error}\n```"; } - return string.IsNullOrWhiteSpace(result.StandardOutput) - ? "_Script completed with no output._" - : result.StandardOutput!; + if (string.IsNullOrWhiteSpace(result.StandardOutput)) + return "_Script completed with no output._"; + + return result.StandardOutput!; } /// Runs the script with the already-built argument line and turns its result into diff --git a/PaletteShellExtension/Package.appxmanifest b/PaletteShellExtension/Package.appxmanifest index 71e062a..cca0ace 100644 --- a/PaletteShellExtension/Package.appxmanifest +++ b/PaletteShellExtension/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="0.0.7.0" /> PaletteShell phireForge diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index ac99772..0a1e807 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -8,6 +8,7 @@ using PaletteShellExtension.Commands; using PaletteShellExtension.Pages; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -28,6 +29,9 @@ internal sealed partial class PaletteShellExtensionPage : ListPage private PinnedScripts? _pins; private List _files = []; private IListItem[]? _cachedItems; + private readonly ConcurrentDictionary _manifestCache = new(StringComparer.OrdinalIgnoreCase); + + private sealed record CachedManifestEntry(long Length, DateTime LastWriteTimeUtc, ScriptManifest? Manifest); public PaletteShellExtensionPage() { @@ -98,6 +102,7 @@ public int RefreshFiles() if (configuredFolder is not null && !string.Equals(configuredFolder, _rootDirectory, StringComparison.OrdinalIgnoreCase)) { _rootDirectory = configuredFolder; + _manifestCache.Clear(); Directory.CreateDirectory(configuredFolder); _pins = new PinnedScripts(configuredFolder); CopySampleScripts(configuredFolder); @@ -105,8 +110,10 @@ public int RefreshFiles() } var rootDirectory = _rootDirectory; + var files = Directory.GetFiles(rootDirectory, "*.ps1", SearchOption.TopDirectoryOnly); _files = [.. files]; + PruneManifestCache(_files); // Use the page's change notification so CmdPal asks for items again. RaiseItemsChanged(); @@ -121,29 +128,48 @@ private static void CopySampleScripts(string root) .Where(n => n.Contains("SampleScripts") && n.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) .ToList(); + var installedSamples = new InstalledSampleScripts(root); + var recordedAnySamples = false; + foreach (var resourceName in resourceNames) { var fileName = resourceName.Split('.').Reverse().Skip(1).First() + ".ps1"; var targetPath = Path.Combine(root, fileName); - // Only copy if the file doesn't exist (don't overwrite user modifications) - if (!File.Exists(targetPath)) + try { - try + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) { - using var stream = assembly.GetManifestResourceStream(resourceName); - if (stream != null) - { - using var reader = new StreamReader(stream, Encoding.UTF8); - var content = reader.ReadToEnd(); - File.WriteAllText(targetPath, content, new UTF8Encoding(false)); - } + continue; } - catch (Exception ex) + + using var reader = new StreamReader(stream, Encoding.UTF8); + var content = reader.ReadToEnd(); + + var targetExists = File.Exists(targetPath); + var onDiskHash = targetExists ? SampleScriptSync.ComputeHash(File.ReadAllText(targetPath, Encoding.UTF8)) : null; + installedSamples.TryGet(fileName, out var record); + var shippedVersion = PowerShellScriptParser.ParseManifestFromContent(content).Version; + + // Only (re)write when it's a fresh install, or a newer shipped version whose + // on-disk copy still matches what we last installed (i.e. not user-modified). + if (SampleScriptSync.ShouldOverwrite(targetExists, record, onDiskHash, shippedVersion)) { - Log.Warn($"Failed to copy sample script '{fileName}': {ex.Message}"); + File.WriteAllText(targetPath, content, new UTF8Encoding(false)); + installedSamples.Record(fileName, shippedVersion, SampleScriptSync.ComputeHash(content), persist: false); + recordedAnySamples = true; } } + catch (Exception ex) + { + Log.Warn($"Failed to copy sample script '{fileName}': {ex.Message}"); + } + } + + if (recordedAnySamples) + { + installedSamples.Save(); } } @@ -243,7 +269,14 @@ public override IListItem[] GetItems() new ListItem(new OpenFolderCommand(rootDirectory)) { Title = "Open scripts folder" }, new ListItem(new ReloadPageCommand(this)) { Title = "Reload scripts" }, new ListItem(new NewScriptWizardPage(rootDirectory)) { Title = "Create new script", Subtitle = "Add a scaffolded .ps1 with metadata headers" }, - new ListItem(new OpenLinkCommand("Find more scripts", "https://github.com/paletteshell/PaletteShellScripts", "๎œ›")) { Title = "Find more scripts", Subtitle = "Browse the PaletteShellScripts repository on GitHub" }, + new ListItem(new LaunchCommunityStoreCommand()) + { + Title = "Browse community scripts", + Subtitle = "Open the Script Manager, or browse the community repo if it isn't installed", + MoreCommands = [ + new CommandContextItem(new OpenLinkCommand("View repository on GitHub", "https://github.com/paletteshell/PaletteShellScripts", "")), + ], + }, ]; // Script items are sorted below: pinned scripts first, then alphabetically by their @@ -261,10 +294,26 @@ public override IListItem[] GetItems() var path = _files[i]; try { - var manifest = PowerShellScriptParser.TryParseManifest(path); + var manifest = GetCachedManifest(path); var title = manifest?.Title ?? Path.GetFileNameWithoutExtension(path); var subtitle = manifest?.Description ?? path; + if (manifest is not null && !AppVersion.IsCompatible(manifest.MinVersion, manifest.MaxVersion, out var requiredVersion, out var tooNew)) + { + var incompatiblePinned = pins.IsPinned(path); + var incompatibleSubtitle = tooNew + ? $"โš  Requires PaletteShell v{requiredVersion} or earlier โ€” you have v{AppVersion.Current}" + : $"โš  Requires PaletteShell v{requiredVersion} or later โ€” you have v{AppVersion.Current}"; + scriptResults[i] = (incompatiblePinned, title, new ListItem(new IncompatibleScriptCommand(requiredVersion!.ToString(), AppVersion.Current.ToString(), tooNew)) + { + Title = title, + Subtitle = incompatibleSubtitle, + Icon = new IconInfo(""), // Warning + MoreCommands = BuildContextCommands(path, pins) + }); + return; + } + var wantsMarkdown = string.Equals(manifest?.Output, "Markdown", StringComparison.OrdinalIgnoreCase); var wantsList = string.Equals(manifest?.Output, "List", StringComparison.OrdinalIgnoreCase); var wantsResult = string.Equals(manifest?.Output, "Result", StringComparison.OrdinalIgnoreCase); @@ -333,8 +382,6 @@ public override IListItem[] GetItems() command = new RunScriptCommand(path, manifest); } - var group = manifest?.Group; - var pinned = pins.IsPinned(path); var listItem = new ListItem(command) @@ -344,7 +391,6 @@ public override IListItem[] GetItems() Icon = !string.IsNullOrWhiteSpace(manifest?.IconGlyph) ? new IconInfo(manifest.IconGlyph) : DefaultScriptIcon, - Tags = BuildTags(pinned, group), MoreCommands = BuildContextCommands(path, pins) }; @@ -363,7 +409,6 @@ public override IListItem[] GetItems() Title = errorTitle, Subtitle = $"โš  Couldn't load this script โ€” open to inspect ({Path.GetFileName(path)})", Icon = new IconInfo("๎žบ"), // Warning - Tags = BuildTags(pinned, null), MoreCommands = BuildContextCommands(path, pins) }); } @@ -385,32 +430,49 @@ public override IListItem[] GetItems() // scannable instead of showing rows with no icon at all. private static readonly IconInfo DefaultScriptIcon = new("๎–"); // CommandPrompt - // Per-script context menu shared by normal and error entries: pin, open, reveal, delete. - private CommandContextItem[] BuildContextCommands(string path, PinnedScripts pins) => - [ - new CommandContextItem(new TogglePinCommand(path, pins, () => RefreshFiles())), - new CommandContextItem(new OpenInEditorCommand(path)), - new CommandContextItem(new RevealInExplorerCommand(path)), - new CommandContextItem(new DeleteScriptCommand(path, () => RefreshFiles())), - ]; - - // Row tags: a "Pinned" marker (when pinned) followed by the script's group, if any. Either - // may be absent, so an unpinned, ungrouped script gets an empty tag list. - private static ITag[] BuildTags(bool pinned, string? group) + private ScriptManifest? GetCachedManifest(string path) { - List tags = []; - if (pinned) + try { - tags.Add(new Tag("๐Ÿ“Œ Pinned")); - } + var info = new FileInfo(path); + if (_manifestCache.TryGetValue(path, out var cached) + && cached.Length == info.Length + && cached.LastWriteTimeUtc == info.LastWriteTimeUtc) + { + return cached.Manifest; + } - if (!string.IsNullOrWhiteSpace(group)) + var manifest = PowerShellScriptParser.TryParseManifest(path); + _manifestCache[path] = new CachedManifestEntry(info.Length, info.LastWriteTimeUtc, manifest); + return manifest; + } + catch (Exception ex) { - tags.Add(new Tag(group)); + Log.Warn($"Failed to stat manifest for '{path}': {ex.Message}"); + _manifestCache.TryRemove(path, out _); + return PowerShellScriptParser.TryParseManifest(path); } + } - return [.. tags]; + private void PruneManifestCache(IEnumerable currentFiles) + { + var current = new HashSet(currentFiles, StringComparer.OrdinalIgnoreCase); + foreach (var cachedPath in _manifestCache.Keys) + { + if (!current.Contains(cachedPath)) + { + _manifestCache.TryRemove(cachedPath, out _); + } + } } + // Per-script context menu shared by normal and error entries: pin, open, reveal, delete. + private CommandContextItem[] BuildContextCommands(string path, PinnedScripts pins) => + [ + new CommandContextItem(new TogglePinCommand(path, pins, () => RefreshFiles())), + new CommandContextItem(new OpenInEditorCommand(path)), + new CommandContextItem(new RevealInExplorerCommand(path)), + new CommandContextItem(new DeleteScriptCommand(path, () => RefreshFiles())), + ]; } diff --git a/PaletteShellExtension/PaletteScriptAttributes.psm1 b/PaletteShellExtension/PaletteScriptAttributes.psm1 index 40a79b3..511cd42 100644 --- a/PaletteShellExtension/PaletteScriptAttributes.psm1 +++ b/PaletteShellExtension/PaletteScriptAttributes.psm1 @@ -35,13 +35,14 @@ class ScriptTimeoutAttribute : Attribute { # Output handling class ScriptOutputAttribute : Attribute { - # None, Toast, Clipboard, Markdown, Result, File, or List. + # None, Toast, Clipboard, Markdown, Result, List, Open, or File. # File writes stdout to a temp file and opens it in the editor; append an # extension hint after a colon to control the file type, e.g. 'File:csv' or 'File:json'. # Result shows stdout as a single copyable result (Enter copies; a "Run again" # command regenerates) โ€” like a calculator answer; good for generators. # List parses stdout (newline-delimited, or a JSON array) into a searchable # results page where each line/object becomes a selectable item. + # Open launches the first non-empty stdout line as a URL, file, or folder path. [string]$Mode ScriptOutputAttribute([string]$mode) { $this.Mode = $mode } } @@ -52,6 +53,35 @@ class ScriptGroupAttribute : Attribute { ScriptGroupAttribute([string]$name) { $this.Name = $name } } +# Free-form tags, comma-delimited (e.g. [ScriptTags('network,dns,admin')]). Used by tooling +# such as the Script Manager's catalog browser. +class ScriptTagsAttribute : Attribute { + [string]$Tags + ScriptTagsAttribute([string]$tags) { $this.Tags = $tags } +} + +# Script version (recommended: SemVer, e.g. '1.0.0'). Lets tools detect when a newer copy is available. +class ScriptVersionAttribute : Attribute { + [string]$Version + ScriptVersionAttribute([string]$version) { $this.Version = $version } +} + +# Minimum PaletteShell app version (SemVer) required to run this script. PaletteShell hides +# the script (with an explanatory row) instead of running it when the installed app is older. +class RequiresPaletteShellMinimumAttribute : Attribute { + [string]$Version + RequiresPaletteShellMinimumAttribute([string]$version) { $this.Version = $version } +} + +# Maximum PaletteShell app version (SemVer) this script still works on - for a script that +# depends on behavior later removed or changed. PaletteShell hides the script (with an +# explanatory row) instead of running it when the installed app is newer. Optional; most scripts +# should omit this and only set RequiresPaletteShellMinimum. +class RequiresPaletteShellMaximumAttribute : Attribute { + [string]$Version + RequiresPaletteShellMaximumAttribute([string]$version) { $this.Version = $version } +} + # Icon emoji or glyph class ScriptIconAttribute : Attribute { [string]$Icon diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 72a40fe..28af7bb 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -16,7 +16,7 @@ phireForge.PaletteShell phireForge - 0.0.6.0 + 0.0.7.0 + + @@ -35,14 +41,6 @@ - - - - - - - - diff --git a/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs b/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs index c2e794f..f0d512b 100644 --- a/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs +++ b/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs @@ -5,6 +5,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using PaletteShellExtension.Classes; +using System; namespace PaletteShellExtension; @@ -14,12 +15,30 @@ public partial class PaletteShellExtensionCommandsProvider : CommandProvider public PaletteShellExtensionCommandsProvider() { + // This constructor runs during COM activation; an exception escaping it kills the + // extension process on every launch. The page/settings constructors guard their own + // failure modes, but this is the backstop: whatever happens, hand the host a valid + // provider โ€” worst case a single entry that says why nothing else is here. DisplayName = "PaletteShell"; - Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); - Settings = PaletteShellSettingsManager.Instance.Settings; - _commands = [ - new CommandItem(new PaletteShellExtensionPage()) { Title = DisplayName, Subtitle = "Run your PowerShell scripts" }, - ]; + try + { + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Settings = PaletteShellSettingsManager.Instance.Settings; + _commands = [ + new CommandItem(new PaletteShellExtensionPage()) { Title = DisplayName, Subtitle = "Run your PowerShell scripts" }, + ]; + } + catch (Exception ex) + { + Log.Error("PaletteShell provider failed to initialize", ex); + _commands = [ + new CommandItem(new NoOpCommand()) + { + Title = DisplayName, + Subtitle = "PaletteShell failed to load โ€” check logs in %LOCALAPPDATA%\\PaletteShell\\logs", + }, + ]; + } } public override ICommandItem[] TopLevelCommands() diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index 494ee1b..451de67 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -168,8 +168,10 @@ internal static ScriptManifest ParseManifestFromContent(string content, string f return expanded; } - private const int MinTimeoutMs = 1000; - private const int MaxTimeoutMs = 600_000; // 10 minutes + // Shared with PaletteShellSettingsManager so the user-configured default timeout is + // held to the same bounds as a script-declared [ScriptTimeout(...)]. + internal const int MinTimeoutMs = 1000; + internal const int MaxTimeoutMs = 600_000; // 10 minutes /// /// Rejects a timeout too small to be meaningful (and negative values, which would throw @@ -330,6 +332,10 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) .Where(t => t.Length > 0) .ToList(); break; + case "ScriptVersion": + // Deprecated script-authored version metadata is ignored for backward + // compatibility with existing scripts that still declare it. + break; case "RequiresPaletteShellMinimum" when values.Count >= 1: manifest.MinVersion = values[0]; break; diff --git a/PaletteShellExtension/Program.cs b/PaletteShellExtension/Program.cs index 9f61deb..a50f419 100644 --- a/PaletteShellExtension/Program.cs +++ b/PaletteShellExtension/Program.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using Microsoft.CommandPalette.Extensions; +using PaletteShellExtension.Classes; using Shmuelie.WinRTServer; using Shmuelie.WinRTServer.CsWinRT; using System; @@ -16,28 +17,53 @@ public class Program [MTAThread] public static void Main(string[] args) { - if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") + // Last-chance visibility: nothing can stop the process from dying on an unhandled + // exception, but writing it to the file log first means a crash report from the + // Store can be matched to an actual exception and stack instead of guessed at. + // Registered before anything else so even activation-time failures are captured. + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Log.Error("FATAL unhandled exception", e.ExceptionObject as Exception); + + TaskScheduler.UnobservedTaskException += (_, e) => + { + Log.Error("Unobserved task exception", e.Exception); + e.SetObserved(); + }; + + try { - global::Shmuelie.WinRTServer.ComServer server = new(); - - ManualResetEvent extensionDisposedEvent = new(false); - - // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. - // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object. - // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. - PaletteShellExtension extensionInstance = new(extensionDisposedEvent); - server.RegisterClass(() => extensionInstance); - server.Start(); - - // This will make the main thread wait until the event is signalled by the extension class. - // Since we have single instance of the extension object, we exit as soon as it is disposed. - extensionDisposedEvent.WaitOne(); - server.Stop(); - server.UnsafeDispose(); + if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") + { + global::Shmuelie.WinRTServer.ComServer server = new(); + + ManualResetEvent extensionDisposedEvent = new(false); + + // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. + // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object. + // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. + PaletteShellExtension extensionInstance = new(extensionDisposedEvent); + server.RegisterClass(() => extensionInstance); + server.Start(); + + // This will make the main thread wait until the event is signalled by the extension class. + // Since we have single instance of the extension object, we exit as soon as it is disposed. + extensionDisposedEvent.WaitOne(); + server.Stop(); + server.UnsafeDispose(); + } + else + { + Console.WriteLine("Not being launched as a Extension... exiting."); + } } - else + catch (Exception ex) { - Console.WriteLine("Not being launched as a Extension... exiting."); + // The AppDomain handler above only sees exceptions that reach the runtime + // unhandled; catching here too guarantees the log line even if a future + // handler registration is reordered. The rethrow keeps the crash visible + // to Windows Error Reporting rather than masking a broken activation. + Log.Error("FATAL: extension host startup failed", ex); + throw; } } } diff --git a/PaletteShellExtension/Properties/PublishProfiles/win-arm64.pubxml b/PaletteShellExtension/Properties/PublishProfiles/win-arm64.pubxml new file mode 100644 index 0000000..78d36bb --- /dev/null +++ b/PaletteShellExtension/Properties/PublishProfiles/win-arm64.pubxml @@ -0,0 +1,15 @@ + + + + + FileSystem + ARM64 + win-arm64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + True + + diff --git a/PaletteShellExtension/Properties/PublishProfiles/win-x64.pubxml b/PaletteShellExtension/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..d28f591 --- /dev/null +++ b/PaletteShellExtension/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,16 @@ +๏ปฟ + + + + FileSystem + x64 + win-x64 + bin\\\win-x64\publish\ + true + false + Release + net9.0-windows10.0.26100.0 + + \ No newline at end of file From 3029f9d6d70a1f76b5a3b85daaa40f1a7e2c2b1f Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:02:58 -0400 Subject: [PATCH 05/21] Improvements for failing scripts --- .../ScriptFailureReportTests.cs | 111 ++++++++++++++++++ PaletteShellExtension/Classes/Log.cs | 5 +- .../Classes/ScriptFailureReport.cs | 86 ++++++++++++++ PaletteShellExtension/Classes/ScriptRunner.cs | 48 ++++++-- .../Commands/OpenFolderCommand.cs | 4 +- .../Commands/RunScriptCommand.cs | 20 ++-- .../Commands/ScriptFailurePresenter.cs | 68 +++++++++++ .../Forms/ScriptParameterForm.cs | 13 +- .../Pages/PaletteShellExtensionPage.cs | 5 + PaletteShellExtension/Pages/ScriptListPage.cs | 16 +-- .../Pages/ScriptResultPage.cs | 12 +- .../PaletteShellExtension.csproj | 17 +-- 12 files changed, 342 insertions(+), 63 deletions(-) create mode 100644 PaletteShellExtension.Tests/ScriptFailureReportTests.cs create mode 100644 PaletteShellExtension/Classes/ScriptFailureReport.cs create mode 100644 PaletteShellExtension/Commands/ScriptFailurePresenter.cs diff --git a/PaletteShellExtension.Tests/ScriptFailureReportTests.cs b/PaletteShellExtension.Tests/ScriptFailureReportTests.cs new file mode 100644 index 0000000..4b56eb4 --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptFailureReportTests.cs @@ -0,0 +1,111 @@ +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptFailureReportTests +{ + [Fact] + public void DescribeOutcome_NullResult_SaysFailedToStart() + { + Assert.Equal("failed to start", ScriptFailureReport.DescribeOutcome(null)); + } + + [Fact] + public void DescribeOutcome_TimedOut_SaysTimedOut() + { + var result = new ScriptRunner.ScriptResult { TimedOut = true }; + Assert.Equal("timed out and was killed", ScriptFailureReport.DescribeOutcome(result)); + } + + [Fact] + public void DescribeOutcome_NonZeroExit_IncludesExitCode() + { + var result = new ScriptRunner.ScriptResult { ExitCode = 42 }; + Assert.Equal("exited with code 42", ScriptFailureReport.DescribeOutcome(result)); + } + + [Fact] + public void Build_ExitCodeFailure_IncludesPathArgsExitCodeAndFullStreams() + { + var longError = new string('e', 5000); + var result = new ScriptRunner.ScriptResult + { + ExitCode = 1, + StandardError = longError, + StandardOutput = "partial output", + DurationMs = 42, + }; + + var report = ScriptFailureReport.Build(@"C:\Scripts\foo.ps1", "pwsh", "-Name 'x'", result); + + Assert.Contains(@"C:\Scripts\foo.ps1", report); + Assert.Contains("-Name 'x'", report); + Assert.Contains("exited with code 1", report); + Assert.Contains("Duration: 42 ms", report); + Assert.Contains(longError, report); // full stderr, no truncation + Assert.Contains("partial output", report); + } + + [Fact] + public void Build_NullResult_SaysFailedToStartWithEmptyStreams() + { + var report = ScriptFailureReport.Build(@"C:\Scripts\foo.ps1", "pwsh", "", null); + + Assert.Contains("failed to start", report); + Assert.Contains("Args: (none)", report); + Assert.Contains("(empty)", report); + Assert.DoesNotContain("Duration:", report); + } + + [Fact] + public void Build_TimedOut_SaysTimedOut() + { + var result = new ScriptRunner.ScriptResult { TimedOut = true, DurationMs = 30000 }; + var report = ScriptFailureReport.Build(@"C:\Scripts\slow.ps1", "pwsh", "", result); + + Assert.Contains("timed out and was killed", report); + Assert.Contains("Duration: 30000 ms", report); + } + + [Fact] + public void StderrForLog_Empty_ReturnsEmpty() + { + Assert.Equal("", ScriptFailureReport.StderrForLog(null)); + Assert.Equal("", ScriptFailureReport.StderrForLog("")); + Assert.Equal("", ScriptFailureReport.StderrForLog(" ")); + } + + [Fact] + public void StderrForLog_MultiLine_CollapsesNewlinesIntoOneLine() + { + var log = ScriptFailureReport.StderrForLog("line one\r\nline two\nline three"); + + Assert.Equal("; stderr: line one | line two | line three", log); + Assert.DoesNotContain("\n", log); + } + + [Fact] + public void StderrForLog_LongInput_TruncatesToMax() + { + var log = ScriptFailureReport.StderrForLog(new string('x', 5000), maxChars: 100); + + Assert.Contains(new string('x', 100) + "โ€ฆ", log); + Assert.DoesNotContain(new string('x', 101), log); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", "")] + [InlineData("plain text", "plain text")] + // pwsh 7's colored Write-Error output: CSI color sequences. + [InlineData("\x1B[31;1mWrite-Error: \x1B[31;1mboom\x1B[0m", "Write-Error: boom")] + // Cursor movement and erase sequences. + [InlineData("progress\x1B[2K\x1B[1Gdone", "progressdone")] + // OSC hyperlink (terminated by ESC \ string terminator). + [InlineData("\x1B]8;;https://example.com\x1B\\link\x1B]8;;\x1B\\", "link")] + public void StripAnsi_RemovesEscapeSequences(string? input, string? expected) + { + Assert.Equal(expected, ScriptRunner.StripAnsi(input)); + } +} diff --git a/PaletteShellExtension/Classes/Log.cs b/PaletteShellExtension/Classes/Log.cs index 4590a43..c03e7b5 100644 --- a/PaletteShellExtension/Classes/Log.cs +++ b/PaletteShellExtension/Classes/Log.cs @@ -13,7 +13,10 @@ namespace PaletteShellExtension.Classes; internal static class Log { private static readonly object WriteLock = new(); - private static readonly string LogDirectory; + + /// Where the daily log files live. Exposed so the palette can offer an + /// "Open log folder" command โ€” otherwise the user has no way to find these files. + internal static string LogDirectory { get; } // A single append-mode writer held for the process lifetime (re-opened when the day // rolls over), so a burst of warnings โ€” e.g. a reload of a folder with several diff --git a/PaletteShellExtension/Classes/ScriptFailureReport.cs b/PaletteShellExtension/Classes/ScriptFailureReport.cs new file mode 100644 index 0000000..c7c5ee4 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptFailureReport.cs @@ -0,0 +1,86 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; + +namespace PaletteShellExtension.Classes; + +/// +/// Builds the full plain-text failure report a user can open in their editor when a script +/// fails โ€” the untruncated counterpart to the 300-char summary in +/// . Pure string formatting so it stays unit-testable +/// without spawning a process. +/// +internal static class ScriptFailureReport +{ + /// One-line outcome, e.g. "exited with code 1", "timed out and was killed", + /// "failed to start". Shared by the report header and the failure dialog title. + public static string DescribeOutcome(ScriptRunner.ScriptResult? result) + { + if (result is null) + return "failed to start"; + + if (result.TimedOut) + return "timed out and was killed"; + + return $"exited with code {result.ExitCode}"; + } + + /// + /// Full failure report: script path, resolved shell, args, outcome, duration (when + /// known), and the complete stderr and stdout streams. + /// + public static string Build(string scriptPath, string host, string args, ScriptRunner.ScriptResult? result) + { + var name = Path.GetFileNameWithoutExtension(scriptPath); + var sb = new StringBuilder(); + + var culture = CultureInfo.InvariantCulture; + sb.AppendLine(culture, $"Script failure report โ€” {name}"); + sb.AppendLine(culture, $"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine(); + sb.AppendLine(culture, $"Script: {scriptPath}"); + sb.AppendLine(culture, $"Shell: {ScriptRunner.ResolveShell(host)}"); + sb.AppendLine(culture, $"Args: {(string.IsNullOrWhiteSpace(args) ? "(none)" : args)}"); + sb.AppendLine(culture, $"Outcome: {DescribeOutcome(result)}"); + if (result?.DurationMs is { } duration) + { + sb.AppendLine(culture, $"Duration: {duration} ms"); + } + sb.AppendLine(); + sb.AppendLine("----- stderr -----"); + sb.AppendLine(ContentOrPlaceholder(result?.StandardError)); + sb.AppendLine(); + sb.AppendLine("----- stdout -----"); + sb.AppendLine(ContentOrPlaceholder(result?.StandardOutput)); + sb.AppendLine(); + sb.AppendLine(culture, $"Logs: {Log.LogDirectory}"); + + return sb.ToString(); + } + + /// + /// Trims stderr for inclusion in a single log line: newlines collapsed, truncated to + /// . Non-empty results come back prefixed with "; stderr: " + /// so callers can append directly to an existing message; empty input yields "". + /// + public static string StderrForLog(string? stderr, int maxChars = 2000) + { + var text = stderr?.Trim(); + if (string.IsNullOrEmpty(text)) + return ""; + + text = text.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", " | "); + if (text.Length > maxChars) + { + text = text[..maxChars] + "โ€ฆ"; + } + return $"; stderr: {text}"; + } + + private static string ContentOrPlaceholder(string? stream) + { + var text = stream?.Trim(); + return string.IsNullOrEmpty(text) ? "(empty)" : text; + } +} diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index 9837bcf..e367790 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -3,11 +3,12 @@ using System.Diagnostics; using System.IO; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace PaletteShellExtension.Classes; -internal static class ScriptRunner +internal static partial class ScriptRunner { private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); @@ -17,6 +18,10 @@ public sealed class ScriptResult public string? StandardOutput { get; set; } public string? StandardError { get; set; } public bool TimedOut { get; set; } + + /// Wall-clock run time, when the runner measured it. Null for results that + /// predate the wait (e.g. a failed start), so reports can omit the line. + public long? DurationMs { get; set; } } /// @@ -39,7 +44,22 @@ public static string DescribeFailure(ScriptResult result) return $"Script failed (exit {result.ExitCode}): {error}"; } - /// Waits briefly for a stream read to finish, returning null on failure or timeout. + // ANSI escape sequences: CSI (colors/cursor), OSC (titles/hyperlinks), and two-character + // escapes. PowerShell 7 colors its error output with these even when stderr is redirected, + // and native tools a script calls (git, npm, โ€ฆ) do the same. + [GeneratedRegex(@"\x1B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)?|[@-_])")] + private static partial Regex AnsiEscapes(); + + /// + /// Removes ANSI escape sequences from captured output. Nothing downstream can render + /// them โ€” toasts, dialogs, pages, and the log are all plain text โ€” so left in they show + /// up as literal "[31;1m" noise. + /// + internal static string? StripAnsi(string? text) + => string.IsNullOrEmpty(text) || !text.Contains('\x1B') ? text : AnsiEscapes().Replace(text, ""); + + /// Waits briefly for a stream read to finish, returning null on failure or + /// timeout. Captured output is ANSI-stripped here โ€” the single funnel for both streams. private static string? AwaitRead(Task? task) { if (task is null) @@ -49,7 +69,7 @@ public static string DescribeFailure(ScriptResult result) try { - return task.Wait(2000) ? task.Result : null; + return task.Wait(2000) ? StripAnsi(task.Result) : null; } catch (Exception) { @@ -68,7 +88,7 @@ public static string DescribeFailure(ScriptResult result) try { - return await task.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + return StripAnsi(await task.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false)); } catch (Exception) { @@ -228,6 +248,8 @@ public static bool RunScript( { Process? proc = null; + var stopwatch = Stopwatch.StartNew(); + var progress = reportProgress ? ScriptStatus.ShowRunning(Path.GetFileNameWithoutExtension(scriptPath)) : null; @@ -272,16 +294,18 @@ public static bool RunScript( catch (TimeoutException) { result.TimedOut = true; - Log.Warn($"Script '{scriptPath}' timed out after {timeoutMs.Value}ms and was killed"); + result.DurationMs = stopwatch.ElapsedMilliseconds; try { proc.Kill(entireProcessTree: true); } catch (Exception) { // Ignore failures killing the process tree. } // Killing closes the pipes, so the reads complete with whatever was - // buffered; capture that partial output before returning. + // buffered; capture that partial output before returning. The Warn comes + // after the drain so it can include the script's stderr. result.StandardOutput = await AwaitReadAsync(stdoutTask).ConfigureAwait(false); result.StandardError = await AwaitReadAsync(stderrTask).ConfigureAwait(false); + Log.Warn($"Script '{scriptPath}' timed out after {timeoutMs.Value}ms and was killed{ScriptFailureReport.StderrForLog(result.StandardError)}"); return result; } } @@ -295,9 +319,10 @@ public static bool RunScript( result.StandardOutput = await AwaitReadAsync(stdoutTask).ConfigureAwait(false); result.StandardError = await AwaitReadAsync(stderrTask).ConfigureAwait(false); result.ExitCode = proc.ExitCode; + result.DurationMs = stopwatch.ElapsedMilliseconds; if (result.ExitCode != 0) { - Log.Warn($"Script '{scriptPath}' exited with code {result.ExitCode}"); + Log.Warn($"Script '{scriptPath}' exited with code {result.ExitCode}{ScriptFailureReport.StderrForLog(result.StandardError)}"); } return result; } @@ -383,16 +408,18 @@ public static bool RunScript( if (!proc.WaitForExit(effectiveTimeoutMs)) { result.TimedOut = true; - Log.Warn($"Script '{scriptName}' timed out after {stopwatch.ElapsedMilliseconds}ms (limit {effectiveTimeoutMs}ms) and was killed"); + result.DurationMs = stopwatch.ElapsedMilliseconds; try { proc.Kill(entireProcessTree: true); } catch (Exception) { // Ignore failures killing the process tree. } // Killing closes the pipes, so the reads complete with whatever was - // buffered; capture that partial output before returning. + // buffered; capture that partial output before returning. The Warn comes + // after the drain so it can include the script's stderr. result.StandardOutput = AwaitRead(stdoutTask); result.StandardError = AwaitRead(stderrTask); + Log.Warn($"Script '{scriptName}' timed out after {stopwatch.ElapsedMilliseconds}ms (limit {effectiveTimeoutMs}ms) and was killed{ScriptFailureReport.StderrForLog(result.StandardError)}"); return result; } @@ -401,9 +428,10 @@ public static bool RunScript( result.StandardOutput = AwaitRead(stdoutTask); result.StandardError = AwaitRead(stderrTask); result.ExitCode = proc.ExitCode; + result.DurationMs = stopwatch.ElapsedMilliseconds; if (result.ExitCode != 0) { - Log.Warn($"Script '{scriptName}' exited with code {result.ExitCode} after {stopwatch.ElapsedMilliseconds}ms"); + Log.Warn($"Script '{scriptName}' exited with code {result.ExitCode} after {stopwatch.ElapsedMilliseconds}ms{ScriptFailureReport.StderrForLog(result.StandardError)}"); } else { diff --git a/PaletteShellExtension/Commands/OpenFolderCommand.cs b/PaletteShellExtension/Commands/OpenFolderCommand.cs index 6f6f66f..132688d 100644 --- a/PaletteShellExtension/Commands/OpenFolderCommand.cs +++ b/PaletteShellExtension/Commands/OpenFolderCommand.cs @@ -3,9 +3,9 @@ namespace PaletteShellExtension.Commands; -internal sealed partial class OpenFolderCommand(string folder) : InvokableCommand +internal sealed partial class OpenFolderCommand(string folder, string name = "Open scripts folder") : InvokableCommand { - public override string Name => "Open scripts folder"; + public override string Name => name; public override IconInfo Icon => new("\uE8B7"); public override CommandResult Invoke() { diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index 1de06af..f492f72 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -51,6 +51,8 @@ private CommandResult RunNow() var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : (int?)null; + var host = _manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; + // "None" never surfaces output, so when there's also no timeout we can run // fire-and-forget without waiting for/capturing stdout. Any other mode // (Toast/Clipboard) needs the output, so we must wait even without a timeout. @@ -60,7 +62,7 @@ private CommandResult RunNow() ScriptRunner.RunScript( scriptPath: path, args: "", - host: _manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, + host: host, cwd: cwd, env: expandedEnv); return CommandResult.ShowToast("Script completed"); @@ -71,21 +73,17 @@ private CommandResult RunNow() var result = ScriptRunner.RunScriptAndWait( scriptPath: path, args: "", - host: _manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, + host: host, cwd: cwd, env: expandedEnv, requiresAdmin: wantsAdmin, timeoutMs: timeout ?? PaletteShellSettingsManager.Instance.DefaultTimeoutMs); - if (result == null) - return CommandResult.ShowToast("Error: Process.Start returned null"); - - if (result.TimedOut) - return CommandResult.ShowToast("Script timed out"); - - // If script failed, return gracefully without further processing - if (result.ExitCode != 0) - return CommandResult.ShowToast(ScriptRunner.DescribeFailure(result)); + // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose + // "View details" opens the full failure report โ€” a toast is too small and too + // short-lived to explain what went wrong. + if (result == null || result.TimedOut || result.ExitCode != 0) + return ScriptFailurePresenter.ToCommandResult(path, host, "", result); // Elevated scripts can't have their output captured, so suppress output handling. var output = !wantsAdmin ? result.StandardOutput : null; diff --git a/PaletteShellExtension/Commands/ScriptFailurePresenter.cs b/PaletteShellExtension/Commands/ScriptFailurePresenter.cs new file mode 100644 index 0000000..dfc9830 --- /dev/null +++ b/PaletteShellExtension/Commands/ScriptFailurePresenter.cs @@ -0,0 +1,68 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using System.IO; + +namespace PaletteShellExtension.Commands; + +/// +/// Shared presentation for script failures. A transient toast gave the user nothing to act +/// on โ€” truncated stderr that vanished before it could be read or copied. Instead, failures +/// surface as a confirmation dialog (or an actionable list row on the List/Result pages) +/// whose primary action opens the full failure report in the user's editor. Centralized here +/// so the ConfirmationArgs wiring isn't duplicated across the run paths. +/// +internal static class ScriptFailurePresenter +{ + /// + /// Failure result for toast-based run paths: a dialog with the short summary whose + /// "View details" command opens the full report via . + /// + public static CommandResult ToCommandResult(string scriptPath, string host, string args, ScriptRunner.ScriptResult? result) + { + var name = Path.GetFileNameWithoutExtension(scriptPath); + return CommandResult.Confirm(new ConfirmationArgs + { + Title = $"{name} {ScriptFailureReport.DescribeOutcome(result)}", + Description = result is null + ? "The script process could not be started." + : ScriptRunner.DescribeFailure(result), + PrimaryCommand = new CallbackCommand("View details", () => + { + OpenReport(scriptPath, host, args, result); + return CommandResult.Dismiss(); + }), + IsPrimaryCommandCritical = false, + }); + } + + /// + /// Failure row for list-shaped pages: Enter opens the full report (keeping the page + /// open), and the context menu offers the log folder. + /// + public static ListItem ToListItem(string scriptPath, string host, string args, ScriptRunner.ScriptResult? result) + { + return new ListItem(new CallbackCommand("View details", () => + { + OpenReport(scriptPath, host, args, result); + return CommandResult.KeepOpen(); + })) + { + Title = result is null ? "Failed to start script." : ScriptRunner.DescribeFailure(result), + Subtitle = "Press Enter to open the full failure report", + Icon = new IconInfo("๎žบ"), // Warning + MoreCommands = + [ + new CommandContextItem(new OpenFolderCommand(Log.LogDirectory, "Open log folder")), + ], + }; + } + + private static void OpenReport(string scriptPath, string host, string args, ScriptRunner.ScriptResult? result) + { + var name = Path.GetFileNameWithoutExtension(scriptPath); + EditorLauncher.OpenContent( + ScriptFailureReport.Build(scriptPath, host, args, result), + ".txt", + $"{name}-failure"); + } +} diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index 6e2f916..47dd9be 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -203,14 +203,11 @@ private CommandResult Execute(string argsLine) requiresAdmin: false, timeoutMs: timeout); - if (result == null) - return CommandResult.ShowToast("Error: Failed to start script"); - - if (result.TimedOut) - return CommandResult.ShowToast("Script timed out"); - - if (result.ExitCode != 0) - return CommandResult.ShowToast(ScriptRunner.DescribeFailure(result)); + // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose + // "View details" opens the full failure report โ€” a toast is too small and too + // short-lived to explain what went wrong. + if (result == null || result.TimedOut || result.ExitCode != 0) + return ScriptFailurePresenter.ToCommandResult(_scriptPath, _host, argsLine, result); // Markdown output - render the result in place instead of a toast. var wantsMarkdown = string.Equals(_manifest.Output, "Markdown", StringComparison.OrdinalIgnoreCase); diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index ba1f0b8..e5849b7 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -352,6 +352,11 @@ public override IListItem[] GetItems() items.AddRange([ new ListItem(new OpenFolderCommand(rootDirectory)) { Title = "Open scripts folder" }, + new ListItem(new OpenFolderCommand(Log.LogDirectory, "Open log folder")) + { + Title = "Open log folder", + Subtitle = "Diagnostic logs for script runs and failures", + }, new ListItem(new ReloadPageCommand(this)) { Title = "Reload scripts" }, new ListItem(new NewScriptWizardPage(rootDirectory)) { Title = "Create new script", Subtitle = "Add a scaffolded .ps1 with metadata headers" }, new ListItem(new LaunchCommunityStoreCommand()) diff --git a/PaletteShellExtension/Pages/ScriptListPage.cs b/PaletteShellExtension/Pages/ScriptListPage.cs index bed846d..066b95e 100644 --- a/PaletteShellExtension/Pages/ScriptListPage.cs +++ b/PaletteShellExtension/Pages/ScriptListPage.cs @@ -171,7 +171,7 @@ private async Task ExecuteAsync(string? query, CancellationToken cancellationTok return; // A newer query arrived while this ran; discard this one. } - _items = BuildItems(result); + _items = BuildItems(result, args); } catch (Exception ex) { @@ -215,16 +215,12 @@ private static IListItem[] Filter(IListItem[] items, string? search) || (i.Subtitle?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false))]; } - private static IListItem[] BuildItems(ScriptRunner.ScriptResult? result) + private IListItem[] BuildItems(ScriptRunner.ScriptResult? result, string args) { - if (result is null) - return [Message("Failed to start script.")]; - - if (result.TimedOut) - return [Message("Script timed out.")]; - - if (result.ExitCode != 0) - return [Message(ScriptRunner.DescribeFailure(result))]; + // Failures get an actionable row (Enter opens the full failure report) instead of + // an inert message, so the user can see the whole error rather than a summary. + if (result is null || result.TimedOut || result.ExitCode != 0) + return [ScriptFailurePresenter.ToListItem(_scriptPath, _host, args, result)]; var output = result.StandardOutput; if (string.IsNullOrWhiteSpace(output)) diff --git a/PaletteShellExtension/Pages/ScriptResultPage.cs b/PaletteShellExtension/Pages/ScriptResultPage.cs index 6d8778f..fa45a6b 100644 --- a/PaletteShellExtension/Pages/ScriptResultPage.cs +++ b/PaletteShellExtension/Pages/ScriptResultPage.cs @@ -112,14 +112,10 @@ private async Task Execute() private IListItem[] BuildItems(ScriptRunner.ScriptResult? result) { - if (result is null) - return [Message("Failed to start script.")]; - - if (result.TimedOut) - return [Message("Script timed out.")]; - - if (result.ExitCode != 0) - return [Message(ScriptRunner.DescribeFailure(result))]; + // Failures get an actionable row (Enter opens the full failure report) instead of + // an inert message, so the user can see the whole error rather than a summary. + if (result is null || result.TimedOut || result.ExitCode != 0) + return [ScriptFailurePresenter.ToListItem(_scriptPath, _host, "", result)]; var value = result.StandardOutput?.Trim(); if (string.IsNullOrEmpty(value)) diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index b447b95..86c05f0 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -18,21 +18,12 @@ phireForge 0.0.7.0 - - true - true + + false - - - - - From 5fe07a87213ae53a36507c86314a95e7483bb8e1 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:27:19 -0400 Subject: [PATCH 06/21] Add back in ScriptVersion --- .gitignore | 3 +- AGENTS.md | 1 + .../PowerShellScriptParserTests.cs | 18 ++- .../ScriptVersionStamperTests.cs | 107 ++++++++++++++++++ .../Classes/ScriptManifest.cs | 6 + .../Classes/ScriptVersionStamper.cs | 104 +++++++++++++++++ .../Forms/NewScriptWizardForm.cs | 4 + .../Pages/PaletteShellExtensionPage.cs | 16 ++- .../PaletteScriptAttributes.psm1 | 3 +- .../PaletteShellExtension.csproj | 5 + .../PowerShellScriptParser.cs | 12 +- .../SampleScripts/Base64-Decode.ps1 | 1 + .../SampleScripts/Base64-Encode.ps1 | 1 + .../SampleScripts/Clear-TempFiles.ps1 | 1 + .../Clipboard-RemoveDuplicateLines.ps1 | 1 + .../SampleScripts/Clipboard-SortLines.ps1 | 1 + .../SampleScripts/Clipboard-ToCSV.ps1 | 1 + .../SampleScripts/Clipboard-ToLowerCase.ps1 | 1 + .../SampleScripts/Clipboard-ToUpperCase.ps1 | 1 + .../SampleScripts/Clipboard-TrimLines.ps1 | 1 + .../SampleScripts/Clipboard-UnixTimestamp.ps1 | 1 + .../SampleScripts/Clipboard-UrlDecode.ps1 | 1 + .../SampleScripts/Clipboard-UrlEncode.ps1 | 1 + .../SampleScripts/Export-ProcessList.ps1 | 1 + .../SampleScripts/Generate-GUID.ps1 | 1 + .../SampleScripts/Get-PublicIP.ps1 | 1 + .../SampleScripts/Git-Branches.ps1 | 1 + .../SampleScripts/Json-Format.ps1 | 1 + .../SampleScripts/Json-Minify.ps1 | 1 + .../SampleScripts/Open-TempFolder.ps1 | 1 + .../SampleScripts/Restart-Explorer.ps1 | 1 + .../SampleScripts/System-Report.ps1 | 1 + .../SampleScripts/Text-Transform.ps1 | 1 + README.md | 9 ++ 34 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 PaletteShellExtension.Tests/ScriptVersionStamperTests.cs create mode 100644 PaletteShellExtension/Classes/ScriptVersionStamper.cs diff --git a/.gitignore b/.gitignore index b8d3b78..7e8409f 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,5 @@ BenchmarkDotNet.Artifacts/ # ========================= !Directory.Build.rsp /PaletteShellExtension/bundle_mapping.txt -signing/ \ No newline at end of file +signing/ +/PaletteShellExtension/*.msixbundle diff --git a/AGENTS.md b/AGENTS.md index facbffa..efe6fb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything e | `[ScriptCwd('{ScriptDir}')]` | Working directory (supports path tokens, below) | | `[ScriptGroup('Category')]` | Group name used by tooling such as the Script Manager catalog browser | | `[ScriptTags('foo,bar,baz')]` | Comma-delimited free-form tags used by tooling such as the Script Manager catalog browser | +| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) โ€” lets tools detect when a newer copy is available. A script that omits it is stamped with `1.0.0` on load | | `[RequiresPaletteShellMinimum('1.2.0')]` | Minimum PaletteShell app version required. If the installed app is older, the row shows "Requires an update" instead of running the script. Defaults to `0.0.6` (the last release before this attribute existed) when omitted | | `[RequiresPaletteShellMaximum('2.0.0')]` | Maximum PaletteShell app version this script still works on. If the installed app is newer, the row shows "Requires an update" instead of running the script. Optional โ€” use only if your script depends on behavior later removed or changed | | `[ScriptIcon('๐Ÿš€')]` | Emoji or glyph shown on the row | diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs index bde1d0b..9108b11 100644 --- a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -358,15 +358,23 @@ public void ScriptTags_Absent_YieldsEmptyList() } [Fact] - public void ScriptVersion_IsIgnored() + public void ScriptVersion_IsParsed() { - using var file = new TestScriptFile("[ScriptVersion('1.2.3')]\nparam()"); + using var file = new TestScriptFile("[ScriptVersion('1.2.0')]\nparam()"); var manifest = PowerShellScriptParser.TryParseManifest(file.Path); - Assert.NotNull(manifest); - Assert.Equal("0.0.6", manifest!.MinVersion); - Assert.Null(manifest.MaxVersion); + Assert.Equal("1.2.0", manifest!.Version); + } + + [Fact] + public void ScriptVersion_WhenOmitted_DefaultsTo1_0_0() + { + using var file = new TestScriptFile("param()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("1.0.0", manifest!.Version); } [Fact] diff --git a/PaletteShellExtension.Tests/ScriptVersionStamperTests.cs b/PaletteShellExtension.Tests/ScriptVersionStamperTests.cs new file mode 100644 index 0000000..f770a16 --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptVersionStamperTests.cs @@ -0,0 +1,107 @@ +using System.IO; +using PaletteShellExtension; +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptVersionStamperTests +{ + [Fact] + public void AddsVersion_ImmediatelyBeforeParam_WhenMissing() + { + using var file = new TestScriptFile("[ScriptGroup('Tools')]\n[CmdletBinding()]\nparam()\n\nWrite-Host 'hi'\n"); + + var stamped = ScriptVersionStamper.TryStamp(file.Path); + + Assert.True(stamped); + var text = File.ReadAllText(file.Path); + Assert.Contains("[ScriptVersion('1.0.0')]", text); + // Inserted at the param block, after the pre-existing attributes. + Assert.Matches(@"\[CmdletBinding\(\)\]\s*\[ScriptVersion\('1\.0\.0'\)\]\s*param\(", text); + // The body is untouched. + Assert.Contains("Write-Host 'hi'", text); + } + + [Fact] + public void StampedScript_ParsesToVersion1_0_0() + { + using var file = new TestScriptFile("[ScriptGroup('Tools')]\nparam()\n"); + + ScriptVersionStamper.TryStamp(file.Path); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + Assert.Equal("1.0.0", manifest!.Version); + } + + [Fact] + public void Idempotent_LeavesAnAlreadyVersionedScriptUntouched() + { + const string original = "[ScriptVersion('2.3.4')]\nparam()\n"; + using var file = new TestScriptFile(original); + + var stamped = ScriptVersionStamper.TryStamp(file.Path); + + Assert.False(stamped); + Assert.Equal(original, File.ReadAllText(file.Path)); + } + + [Fact] + public void RunningTwice_StampsOnlyOnce() + { + using var file = new TestScriptFile("param()\n"); + + Assert.True(ScriptVersionStamper.TryStamp(file.Path)); + Assert.False(ScriptVersionStamper.TryStamp(file.Path)); + + var text = File.ReadAllText(file.Path); + Assert.Single(System.Text.RegularExpressions.Regex.Matches(text, @"\[ScriptVersion\(")); + } + + [Fact] + public void SkipsScript_WithNoParamBlock() + { + const string original = "[ScriptGroup('Tools')]\nWrite-Host 'no param here'\n"; + using var file = new TestScriptFile(original); + + var stamped = ScriptVersionStamper.TryStamp(file.Path); + + Assert.False(stamped); + Assert.Equal(original, File.ReadAllText(file.Path)); + } + + [Fact] + public void DoesNotMatch_ParamKeywordInsideAComment() + { + // "param(" only appears in a comment, never as a real block - nothing safe to attach to. + const string original = "# this script has no real param( block\nWrite-Host 'x'\n"; + using var file = new TestScriptFile(original); + + Assert.False(ScriptVersionStamper.TryStamp(file.Path)); + Assert.Equal(original, File.ReadAllText(file.Path)); + } + + [Fact] + public void PreservesCrlfNewlines() + { + using var file = new TestScriptFile("[ScriptGroup('Tools')]\r\nparam()\r\n"); + + ScriptVersionStamper.TryStamp(file.Path); + + var text = File.ReadAllText(file.Path); + Assert.Contains("[ScriptVersion('1.0.0')]\r\n", text); + Assert.DoesNotContain("[ScriptVersion('1.0.0')]\n\n", text); // no stray lone-LF introduced + } + + [Fact] + public void PreservesLfNewlines() + { + using var file = new TestScriptFile("[ScriptGroup('Tools')]\nparam()\n"); + + ScriptVersionStamper.TryStamp(file.Path); + + var text = File.ReadAllText(file.Path); + Assert.Contains("[ScriptVersion('1.0.0')]\n", text); + Assert.DoesNotContain("\r\n", text); // an LF file stays LF + } +} diff --git a/PaletteShellExtension/Classes/ScriptManifest.cs b/PaletteShellExtension/Classes/ScriptManifest.cs index d2188ae..623a640 100644 --- a/PaletteShellExtension/Classes/ScriptManifest.cs +++ b/PaletteShellExtension/Classes/ScriptManifest.cs @@ -13,6 +13,12 @@ public sealed class ScriptManifest public List Tags { get; set; } = []; public string? IconGlyph { get; set; } + // Script-authored version from [ScriptVersion('x')] (recommended SemVer). Defaults to "1.0.0" + // when a script omits it (see PowerShellScriptParser) so every manifest stays comparable + // rather than making callers special-case a null. Lets tooling detect when a newer copy of a + // script is available. + public string? Version { get; set; } + // Minimum PaletteShell app version (SemVer) required to run this script. A script that // omits [RequiresPaletteShellMinimum(...)] defaults to "0.0.6" (see PowerShellScriptParser) // rather than staying null, so this is only null when parsing raw content that skips that diff --git a/PaletteShellExtension/Classes/ScriptVersionStamper.cs b/PaletteShellExtension/Classes/ScriptVersionStamper.cs new file mode 100644 index 0000000..4d644d0 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptVersionStamper.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace PaletteShellExtension.Classes; + +/// +/// Backfills a [ScriptVersion('1.0.0')] attribute into .ps1 scripts that don't declare +/// one, so every script PaletteShell manages carries a version rather than relying on the +/// parser's in-memory default. Runs opportunistically during the folder scan. +/// +/// +/// This mutates the user's own files, so every operation is conservative and reversible-by-default: +/// +/// It only ever adds the attribute, and only when none is present (idempotent). +/// It skips a script with no top-level param(...) block - there's no safe, valid place +/// to attach a bare attribute in that case, and a wrong insertion could break the script. +/// It preserves the file's existing newline style and UTF-8 BOM (or lack of one), and skips +/// UTF-16 files entirely rather than risk re-encoding them. +/// The write is atomic (temp file + move), and any failure is swallowed - a scan must never +/// fail, or leave a half-written script, over a best-effort version stamp. +/// +/// +internal static partial class ScriptVersionStamper +{ + private const string DefaultVersion = "1.0.0"; + + /// Adds [ScriptVersion('1.0.0')] to the script at if + /// it declares no version. Returns true only when the file was actually rewritten. + public static bool TryStamp(string ps1Path) + { + try + { + var bytes = File.ReadAllBytes(ps1Path); + + // A UTF-16 BOM means re-encoding as UTF-8 (what we'd write) would corrupt the file, and + // the rest of PaletteShell reads scripts as UTF-8 anyway - leave these well alone. + if (HasUtf16Bom(bytes)) + { + return false; + } + + var hasBom = HasUtf8Bom(bytes); + var bomLength = hasBom ? 3 : 0; + var content = Encoding.UTF8.GetString(bytes, bomLength, bytes.Length - bomLength); + + if (HasScriptVersion(content)) + { + return false; + } + + // Attach the attribute to the top-level param block - the one position that's both a + // valid PowerShell attribute target and where the parser (and every scaffolded script) + // already puts the [Script*] attributes. No param block: nothing safe to attach to. + var param = ParamBlockRegex().Match(content); + if (!param.Success) + { + return false; + } + + var newline = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var insertion = $"[ScriptVersion('{DefaultVersion}')]{newline}"; + var stamped = content.Insert(param.Index, insertion); + + WriteAtomic(ps1Path, stamped, hasBom); + return true; + } + catch (Exception ex) + { + // Locked file, permissions, a torn read - none of it is worth failing the scan over. + Log.Warn($"Couldn't stamp ScriptVersion into '{ps1Path}': {ex.Message}"); + return false; + } + } + + private static bool HasUtf8Bom(byte[] b) => + b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF; + + private static bool HasUtf16Bom(byte[] b) => + b.Length >= 2 && ((b[0] == 0xFF && b[1] == 0xFE) || (b[0] == 0xFE && b[1] == 0xFF)); + + private static bool HasScriptVersion(string content) => ScriptVersionRegex().IsMatch(content); + + private static void WriteAtomic(string path, string content, bool withBom) + { + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: withBom); + var tempPath = path + ".psver.tmp"; + File.WriteAllText(tempPath, content, encoding); + File.Move(tempPath, path, overwrite: true); + } + + // A [ScriptVersion ...] attribute anywhere - conservative: if the token appears at all we treat + // the script as already versioned and leave it untouched, even if it's only in a comment. + [GeneratedRegex(@"\[\s*ScriptVersion\b", RegexOptions.IgnoreCase)] + private static partial Regex ScriptVersionRegex(); + + // A top-level param block at the start of a line, allowing inline attributes (e.g. + // "[CmdletBinding()] param("). Anchoring to line start keeps this from matching a stray + // "param(" buried in a comment or string. The match starts at column 0 so the stamp is + // inserted on its own line immediately above. + [GeneratedRegex(@"(?im)^[ \t]*(?:\[[^\]\r\n]*\][ \t]*)*param[ \t]*\(")] + private static partial Regex ParamBlockRegex(); +} diff --git a/PaletteShellExtension/Forms/NewScriptWizardForm.cs b/PaletteShellExtension/Forms/NewScriptWizardForm.cs index 579cddd..1ef130a 100644 --- a/PaletteShellExtension/Forms/NewScriptWizardForm.cs +++ b/PaletteShellExtension/Forms/NewScriptWizardForm.cs @@ -262,6 +262,10 @@ private static string BuildHeader(string name, ScriptOptions options) var group = string.IsNullOrWhiteSpace(options.Group) ? "General" : options.Group; sb.Append(CultureInfo.InvariantCulture, $"[ScriptGroup('{EscapeSingleQuoted(group)}')]\n"); + // Every script starts at 1.0.0 unless the author bumps it; stamping it here means every + // PaletteShell-authored script carries a version, so tooling never has to guess one. + sb.Append("[ScriptVersion('1.0.0')]\n"); + // Stamps the app version the script was scaffolded against, so a copy of this script // taken to an older PaletteShell install shows "Requires an update" instead of running // against attributes/behavior it doesn't recognize yet. diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index e5849b7..700fc03 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -151,7 +151,21 @@ public int RefreshFiles() // Enumerate as FileInfo rather than paths: the directory listing already carries each // file's size and write time, so the manifest-cache check in GetItems can reuse them // instead of paying a second stat per script (noticeable on synced/network folders). - _files = [.. new DirectoryInfo(rootDirectory).EnumerateFiles("*.ps1", SearchOption.TopDirectoryOnly)]; + var discovered = new DirectoryInfo(rootDirectory).EnumerateFiles("*.ps1", SearchOption.TopDirectoryOnly).ToList(); + + // Backfill a [ScriptVersion('1.0.0')] into any script that declares no version. + // Idempotent and best-effort (see ScriptVersionStamper), so an already-stamped folder + // just pays a cheap read per file. Refresh the FileInfo of anything actually rewritten + // so its size/write-time - which the manifest cache keys on - reflects the new content. + foreach (var file in discovered) + { + if (ScriptVersionStamper.TryStamp(file.FullName)) + { + file.Refresh(); + } + } + + _files = [.. discovered]; PruneManifestCache(_files.Select(f => f.FullName)); _folderError = null; } diff --git a/PaletteShellExtension/PaletteScriptAttributes.psm1 b/PaletteShellExtension/PaletteScriptAttributes.psm1 index d57940e..511cd42 100644 --- a/PaletteShellExtension/PaletteScriptAttributes.psm1 +++ b/PaletteShellExtension/PaletteScriptAttributes.psm1 @@ -60,8 +60,7 @@ class ScriptTagsAttribute : Attribute { ScriptTagsAttribute([string]$tags) { $this.Tags = $tags } } -# Deprecated no-op kept so existing scripts that still declare [ScriptVersion(...)] continue -# to run. PaletteShell ignores this metadata. +# Script version (recommended: SemVer, e.g. '1.0.0'). Lets tools detect when a newer copy is available. class ScriptVersionAttribute : Attribute { [string]$Version ScriptVersionAttribute([string]$version) { $this.Version = $version } diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 86c05f0..40275f2 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -18,6 +18,11 @@ phireForge 0.0.7.0 + + true + 655B3250FBBF0E38A4B98C8F44B7DD5E9A9DEC56 + diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index 451de67..efefc89 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -95,6 +95,11 @@ internal static ScriptManifest ParseManifestFromContent(string content, string f // Script-level [Script*] attributes live before the param keyword. ParseScriptAttributes(attributeZone, manifest); + // A script that predates [ScriptVersion(...)] (or simply omits it) is assumed to be + // at the baseline version rather than "no version" - keeps every manifest comparable + // instead of making callers special-case a null. + manifest.Version ??= "1.0.0"; + // A script that predates [RequiresPaletteShellMinimum(...)] (or simply omits it) is // assumed to require no more than 0.0.6 - the last PaletteShell version before // RequiresPaletteShellMinimum itself existed. 1.0.0 would be the wrong baseline here: @@ -332,9 +337,8 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) .Where(t => t.Length > 0) .ToList(); break; - case "ScriptVersion": - // Deprecated script-authored version metadata is ignored for backward - // compatibility with existing scripts that still declare it. + case "ScriptVersion" when values.Count >= 1: + manifest.Version = values[0]; break; case "RequiresPaletteShellMinimum" when values.Count >= 1: manifest.MinVersion = values[0]; @@ -540,7 +544,7 @@ private static string MapType(string psType, bool hasValidateSet) "AllowEmptyCollection", "CmdletBinding", "OutputType", "Alias", "SupportsWildcards", "PSDefaultValue", "ArgumentCompleter", "ScriptHost", "ScriptCwd", "RequiresElevation", "ConfirmBeforeRun", "ScriptTimeout", - "ScriptOutput", "ScriptIcon", "ScriptGroup", "ScriptTags", "ScriptEnv", + "ScriptOutput", "ScriptIcon", "ScriptGroup", "ScriptTags", "ScriptEnv", "ScriptVersion", "RequiresPaletteShellMinimum", "RequiresPaletteShellMaximum" }; diff --git a/PaletteShellExtension/SampleScripts/Base64-Decode.ps1 b/PaletteShellExtension/SampleScripts/Base64-Decode.ps1 index 4b1db61..052dfc0 100644 --- a/PaletteShellExtension/SampleScripts/Base64-Decode.ps1 +++ b/PaletteShellExtension/SampleScripts/Base64-Decode.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”“')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Base64-Encode.ps1 b/PaletteShellExtension/SampleScripts/Base64-Encode.ps1 index 3317e7d..6315f0a 100644 --- a/PaletteShellExtension/SampleScripts/Base64-Encode.ps1 +++ b/PaletteShellExtension/SampleScripts/Base64-Encode.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clear-TempFiles.ps1 b/PaletteShellExtension/SampleScripts/Clear-TempFiles.ps1 index 8535392..139fb03 100644 --- a/PaletteShellExtension/SampleScripts/Clear-TempFiles.ps1 +++ b/PaletteShellExtension/SampleScripts/Clear-TempFiles.ps1 @@ -13,6 +13,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('System')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿงน')] [ScriptTimeout(60000)] [ScriptOutput('Toast')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-RemoveDuplicateLines.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-RemoveDuplicateLines.ps1 index 34a10cb..98e3760 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-RemoveDuplicateLines.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-RemoveDuplicateLines.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-SortLines.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-SortLines.ps1 index 0bb2016..aece95a 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-SortLines.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-SortLines.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”ค')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-ToCSV.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-ToCSV.ps1 index 19bacb0..80e3a7a 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-ToCSV.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-ToCSV.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ“‹')] [ScriptTimeout(15000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-ToLowerCase.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-ToLowerCase.ps1 index 87cb2e6..0bfb957 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-ToLowerCase.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-ToLowerCase.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”ก')] [ScriptTimeout(5000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-ToUpperCase.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-ToUpperCase.ps1 index 6c0dc34..20869c4 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-ToUpperCase.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-ToUpperCase.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ” ')] [ScriptTimeout(5000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-TrimLines.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-TrimLines.ps1 index 8988560..d7b753e 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-TrimLines.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-TrimLines.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('โœ‚๏ธ')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-UnixTimestamp.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-UnixTimestamp.ps1 index 3c8a7a0..0c80dae 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-UnixTimestamp.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-UnixTimestamp.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('โฐ')] [ScriptTimeout(5000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-UrlDecode.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-UrlDecode.ps1 index 376fe4b..d704cae 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-UrlDecode.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-UrlDecode.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”—')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Clipboard-UrlEncode.ps1 b/PaletteShellExtension/SampleScripts/Clipboard-UrlEncode.ps1 index 7ecf66e..9cd5007 100644 --- a/PaletteShellExtension/SampleScripts/Clipboard-UrlEncode.ps1 +++ b/PaletteShellExtension/SampleScripts/Clipboard-UrlEncode.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐ŸŒ')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Export-ProcessList.ps1 b/PaletteShellExtension/SampleScripts/Export-ProcessList.ps1 index fc02196..0f1ec81 100644 --- a/PaletteShellExtension/SampleScripts/Export-ProcessList.ps1 +++ b/PaletteShellExtension/SampleScripts/Export-ProcessList.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ“Š')] [ScriptTimeout(15000)] [ScriptOutput('File:csv')] diff --git a/PaletteShellExtension/SampleScripts/Generate-GUID.ps1 b/PaletteShellExtension/SampleScripts/Generate-GUID.ps1 index 9d8bb17..a275a80 100644 --- a/PaletteShellExtension/SampleScripts/Generate-GUID.ps1 +++ b/PaletteShellExtension/SampleScripts/Generate-GUID.ps1 @@ -9,6 +9,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ†”')] [ScriptTimeout(5000)] [ScriptOutput('Result')] diff --git a/PaletteShellExtension/SampleScripts/Get-PublicIP.ps1 b/PaletteShellExtension/SampleScripts/Get-PublicIP.ps1 index 34ed2fc..9401e3a 100644 --- a/PaletteShellExtension/SampleScripts/Get-PublicIP.ps1 +++ b/PaletteShellExtension/SampleScripts/Get-PublicIP.ps1 @@ -9,6 +9,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐ŸŒ')] [ScriptTimeout(10000)] [ScriptOutput('Result')] diff --git a/PaletteShellExtension/SampleScripts/Git-Branches.ps1 b/PaletteShellExtension/SampleScripts/Git-Branches.ps1 index 6b039e3..2b285da 100644 --- a/PaletteShellExtension/SampleScripts/Git-Branches.ps1 +++ b/PaletteShellExtension/SampleScripts/Git-Branches.ps1 @@ -12,6 +12,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Developer')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐ŸŒฟ')] [ScriptTimeout(15000)] [ScriptOutput('List')] diff --git a/PaletteShellExtension/SampleScripts/Json-Format.ps1 b/PaletteShellExtension/SampleScripts/Json-Format.ps1 index a22fab7..38081f4 100644 --- a/PaletteShellExtension/SampleScripts/Json-Format.ps1 +++ b/PaletteShellExtension/SampleScripts/Json-Format.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ“„')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Json-Minify.ps1 b/PaletteShellExtension/SampleScripts/Json-Minify.ps1 index a889f65..945e7bc 100644 --- a/PaletteShellExtension/SampleScripts/Json-Minify.ps1 +++ b/PaletteShellExtension/SampleScripts/Json-Minify.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Clipboard')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ—œ๏ธ')] [ScriptTimeout(10000)] [ScriptOutput('None')] diff --git a/PaletteShellExtension/SampleScripts/Open-TempFolder.ps1 b/PaletteShellExtension/SampleScripts/Open-TempFolder.ps1 index f435bb3..3929bed 100644 --- a/PaletteShellExtension/SampleScripts/Open-TempFolder.ps1 +++ b/PaletteShellExtension/SampleScripts/Open-TempFolder.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ“‚')] [ScriptTimeout(5000)] [ScriptOutput('Open')] diff --git a/PaletteShellExtension/SampleScripts/Restart-Explorer.ps1 b/PaletteShellExtension/SampleScripts/Restart-Explorer.ps1 index a198657..997932b 100644 --- a/PaletteShellExtension/SampleScripts/Restart-Explorer.ps1 +++ b/PaletteShellExtension/SampleScripts/Restart-Explorer.ps1 @@ -10,6 +10,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('System')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”')] [ScriptTimeout(10000)] [ScriptOutput('Toast')] diff --git a/PaletteShellExtension/SampleScripts/System-Report.ps1 b/PaletteShellExtension/SampleScripts/System-Report.ps1 index ef8a282..4d02b83 100644 --- a/PaletteShellExtension/SampleScripts/System-Report.ps1 +++ b/PaletteShellExtension/SampleScripts/System-Report.ps1 @@ -8,6 +8,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ–ฅ๏ธ')] [ScriptTimeout(15000)] [ScriptOutput('Markdown')] diff --git a/PaletteShellExtension/SampleScripts/Text-Transform.ps1 b/PaletteShellExtension/SampleScripts/Text-Transform.ps1 index 8f9650c..e72cbcd 100644 --- a/PaletteShellExtension/SampleScripts/Text-Transform.ps1 +++ b/PaletteShellExtension/SampleScripts/Text-Transform.ps1 @@ -21,6 +21,7 @@ using module .\PaletteScriptAttributes.psm1 #> [ScriptHost('pwsh')] [ScriptGroup('Text Utilities')] +[ScriptVersion('1.0.0')] [ScriptIcon('๐Ÿ”„')] [ScriptTimeout(15000)] [ScriptOutput('None')] diff --git a/README.md b/README.md index 6e54a47..05f6fcf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ Scripts are ordered **pinned first, then alphabetically by their displayed title If a script fails to parse (e.g. a malformed `param()` block), it isn't dropped silently โ€” it still appears with a **โš  Couldn't load this script** subtitle and its context menu, so you can open it to fix or delete it. +A script that declares `[RequiresPaletteShellMinimum(...)]` / `[RequiresPaletteShellMaximum(...)]` outside the installed app's version range also stays visible, but shows a **โš  Requires an update** row (naming the version it needs) instead of running, so you know to update PaletteShell rather than seeing a broken script. + +Scripts are also **version-stamped on load**: any script that declares no `[ScriptVersion(...)]` gets `[ScriptVersion('1.0.0')]` backfilled into it (idempotent and best-effort โ€” files it can't safely rewrite are left untouched), so every managed script carries a version tools can compare. + > โ„น๏ธ New scripts and edits are picked up only when you run **"Reload scripts"** โ€” this is intentional, not a bug. Reloading shows a **"Reloaded N scripts"** toast so you know the rescan ran. ### Parsing the manifest @@ -188,6 +192,10 @@ The agent reads `AGENTS.md`, produces a compliant script in the folder, and you | `[ConfirmBeforeRun('message')]` | Prompt a yes/no confirmation (with `message`) before running | | `[ScriptTimeout(30000)]` | Timeout in milliseconds; also forces wait-and-capture. Omit it to use the [default timeout setting](#-settings) | | `[ScriptGroup('Category')]` | Group/category name for tooling such as the Script Manager catalog browser | +| `[ScriptTags('foo,bar')]` | Comma-delimited free-form tags for tooling such as the Script Manager catalog browser | +| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) so tooling can detect when a newer copy is available. Scripts that omit it are stamped with `1.0.0` on load | +| `[RequiresPaletteShellMinimum('1.2.0')]` | Minimum PaletteShell version required; older installs show a **"Requires an update"** row instead of running | +| `[RequiresPaletteShellMaximum('2.0.0')]` | Maximum PaletteShell version supported; newer installs show a **"Requires an update"** row instead of running | | `[ScriptIcon('๐Ÿš€')]` | Icon emoji or glyph shown in the palette | | `[ScriptOutput('None')]` | Output mode (see below) | | `[ScriptEnv('VAR', 'value')]` | Set an environment variable (repeat for multiple) | @@ -385,6 +393,7 @@ dotnet build PaletteShellExtension/PaletteShellExtension.csproj | `Classes/ScriptOutputHandler.cs` | Maps captured output to a result per the script's output mode | | `Classes/ScriptStatus.cs` | Shows the "Runningโ€ฆ" spinner in the status bar while a script runs | | `Classes/PinnedScripts.cs` | Tracks pinned scripts (persisted to `pinned.txt`) so they sort to the top | +| `Classes/ScriptVersionStamper.cs` | Backfills `[ScriptVersion('1.0.0')]` into scripts that omit it during the folder scan (idempotent, best-effort) | | `Classes/RecycleBin.cs` | Sends a deleted script to the Windows Recycle Bin via `SHFileOperation` | | `Classes/EditorLauncher.cs` | Opens a script in the preferred editor setting, `$VISUAL`/`$EDITOR`, or Notepad | | `Classes/PaletteShellSettingsManager.cs` | Backs the Settings page (scripts folder, default host, default timeout, preferred editor) and persists it to `settings.json` | From 7c7a562ab239303494b151f21fcabbb9d5155830 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:04:27 -0400 Subject: [PATCH 07/21] required modules and script elevation fixes --- AGENTS.md | 3 +- .../ScriptElevationTests.cs | 59 +++++++++++++++ .../Classes/ScriptElevation.cs | 33 ++++++++ .../Classes/ScriptManifest.cs | 5 ++ PaletteShellExtension/Classes/ScriptRunner.cs | 75 +++++++++++++++---- .../Commands/ElevationIncompatibleCommand.cs | 18 +++++ .../Commands/RunScriptCommand.cs | 7 +- .../Forms/NewScriptWizardForm.cs | 31 +++++++- .../Forms/ScriptParameterForm.cs | 19 ++++- .../Pages/PaletteShellExtensionPage.cs | 16 ++++ PaletteShellExtension/Pages/ScriptListPage.cs | 3 +- .../Pages/ScriptMarkdownPage.cs | 3 +- .../Pages/ScriptResultPage.cs | 3 +- .../PaletteScriptAttributes.psm1 | 9 +++ .../PowerShellScriptParser.cs | 5 +- README.md | 2 +- 16 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 PaletteShellExtension.Tests/ScriptElevationTests.cs create mode 100644 PaletteShellExtension/Classes/ScriptElevation.cs create mode 100644 PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs diff --git a/AGENTS.md b/AGENTS.md index efe6fb0..2567e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,8 @@ Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything e | `[ScriptOutput('None')]` | How stdout is handled (see [Output modes](#output-modes)) | | `[ScriptTimeout(30000)]` | Timeout in **milliseconds**; also forces wait-and-capture | | `[ScriptEnv('VAR', 'value')]` | Set an environment variable; repeat the attribute for more than one | -| `[RequiresElevation()]` | Run elevated (admin). Equivalent to `#Requires -RunAsAdministrator`. Output capture is unavailable when elevated. | +| `[RequiresModule('ImportExcel')]` | PowerShell module the script needs installed; repeat the attribute for more than one. Checked at run time โ€” a missing module fails the run with an `Install-Module -Name โ€ฆ -Scope CurrentUser` hint instead of the script's own cryptic error | +| `[RequiresElevation()]` | Run elevated (admin). Equivalent to `#Requires -RunAsAdministrator`. Output capture is unavailable when elevated, so elevation is only compatible with `[ScriptOutput('None')]` โ€” any other output mode makes the script an incompatible row instead of running. | | `[ConfirmBeforeRun('message')]` | Show a yes/no dialog with `message` before running. Pair with `[RequiresElevation()]` for destructive scripts. | ### Path tokens diff --git a/PaletteShellExtension.Tests/ScriptElevationTests.cs b/PaletteShellExtension.Tests/ScriptElevationTests.cs new file mode 100644 index 0000000..04b275f --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptElevationTests.cs @@ -0,0 +1,59 @@ +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptElevationTests +{ + private static ScriptManifest Manifest(bool? admin, string? output) => + new() { RequiresAdmin = admin, Output = output! }; + + [Theory] + // Elevated + capturing output modes are impossible โ†’ incompatible. + [InlineData(true, "Toast", true)] + [InlineData(true, "Clipboard", true)] + [InlineData(true, "Markdown", true)] + [InlineData(true, "Result", true)] + [InlineData(true, "List", true)] + [InlineData(true, "Open", true)] + [InlineData(true, "File", true)] + [InlineData(true, "File:csv", true)] + // Elevated + None is the one allowed combination. + [InlineData(true, "None", false)] + [InlineData(true, "none", false)] + [InlineData(true, null, false)] + [InlineData(true, "", false)] + // Non-elevated is never blocked, regardless of output mode. + [InlineData(false, "Toast", false)] + [InlineData(false, "Markdown", false)] + [InlineData(false, "None", false)] + [InlineData(null, "List", false)] + public void IsElevatedOutputIncompatible_MatchesCapabilityMatrix(bool? admin, string? output, bool expected) + { + Assert.Equal(expected, ScriptElevation.IsElevatedOutputIncompatible(Manifest(admin, output))); + } + + [Theory] + [InlineData("None", false)] + [InlineData("none", false)] + [InlineData("NONE", false)] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("Toast", true)] + [InlineData("Markdown", true)] + [InlineData("File:json", true)] + public void CapturesOutput_TrueForEveryModeExceptNone(string? output, bool expected) + { + Assert.Equal(expected, ScriptElevation.CapturesOutput(Manifest(false, output))); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(null, false)] + public void RequiresElevation_ReflectsRequiresAdmin(bool? admin, bool expected) + { + Assert.Equal(expected, ScriptElevation.RequiresElevation(Manifest(admin, "None"))); + } +} diff --git a/PaletteShellExtension/Classes/ScriptElevation.cs b/PaletteShellExtension/Classes/ScriptElevation.cs new file mode 100644 index 0000000..22a0d0e --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptElevation.cs @@ -0,0 +1,33 @@ +using System; + +namespace PaletteShellExtension.Classes; + +/// +/// Single source of truth for how elevation and output capture interact. Elevation launches +/// the process with runas + UseShellExecute=true, which cannot redirect stdout โ€” +/// so the only output mode compatible with elevation is None. Every execution route +/// consults these helpers so the rule can't drift between routes. +/// +internal static class ScriptElevation +{ + /// True when the script asked to run elevated (via [RequiresElevation()] + /// or #Requires -RunAsAdministrator). + public static bool RequiresElevation(ScriptManifest manifest) + => manifest.RequiresAdmin == true; + + /// True when the declared output mode needs captured stdout. None is the + /// only non-capturing mode; every other mode (Toast/Clipboard/Markdown/Result/List/Open/ + /// File, or any unrecognized value that falls through to Toast) surfaces output. + public static bool CapturesOutput(ScriptManifest manifest) + => !string.IsNullOrWhiteSpace(manifest.Output) + && !string.Equals(manifest.Output, "None", StringComparison.OrdinalIgnoreCase); + + /// True when the script wants elevation but also an output mode that needs + /// capture โ€” an impossible combination that must be blocked rather than run unelevated. + public static bool IsElevatedOutputIncompatible(ScriptManifest manifest) + => RequiresElevation(manifest) && CapturesOutput(manifest); + + /// Subtitle shown on the blocked row and in its explanatory toast. + public static string IncompatibleReason() + => "โš  Elevated scripts can't return output โ€” use [ScriptOutput('None')] or remove elevation"; +} diff --git a/PaletteShellExtension/Classes/ScriptManifest.cs b/PaletteShellExtension/Classes/ScriptManifest.cs index 623a640..c306f57 100644 --- a/PaletteShellExtension/Classes/ScriptManifest.cs +++ b/PaletteShellExtension/Classes/ScriptManifest.cs @@ -31,6 +31,11 @@ public sealed class ScriptManifest // is the correct default here. public string? MaxVersion { get; set; } + // Modules the script needs installed, from one or more [RequiresModule('Name')] attributes. + // Checked at run time (a preflight in the PowerShell command): a missing module fails the + // run with an Install-Module hint rather than the script's own cryptic "term not recognized". + public List RequiredModules { get; set; } = []; + public bool? RequiresAdmin { get; set; } // When set, running the script first prompts a confirmation dialog carrying this diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index e367790..5d51b5b 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -136,6 +136,41 @@ private static bool CanResolveOnPath(string exe) return false; } + /// + /// Builds a PowerShell preflight that fails the run (exit 1) with an Install-Module hint + /// for the first required module that isn't available, or an empty string when nothing is + /// required. Names are embedded as single-quoted literals with quotes doubled, so an odd + /// module name can't break out of the string or inject commands. + /// + private static string BuildModuleCheck(IReadOnlyList? requiredModules) + { + if (requiredModules is null || requiredModules.Count == 0) + { + return ""; + } + + var quoted = new List(requiredModules.Count); + foreach (var name in requiredModules) + { + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + quoted.Add("'" + name.Replace("'", "''") + "'"); + } + + if (quoted.Count == 0) + { + return ""; + } + + var list = string.Join(",", quoted); + return "foreach ($__psRequired in @(" + list + ")) { " + + "if (-not (Get-Module -ListAvailable -Name $__psRequired)) { " + + "Write-Error \"Missing required module '$__psRequired'. Install it with: Install-Module -Name $__psRequired -Scope CurrentUser\"; " + + "exit 1 } }; "; + } + public static ProcessStartInfo BuildProcessStartInfo( string scriptPath, string args, @@ -143,7 +178,8 @@ public static ProcessStartInfo BuildProcessStartInfo( string? cwd, Dictionary? env = null, bool requiresAdmin = false, - bool captureOutput = false) + bool captureOutput = false, + IReadOnlyList? requiredModules = null) { var shell = ResolveShell(host); var psi = new ProcessStartInfo(shell); @@ -159,14 +195,19 @@ public static ProcessStartInfo BuildProcessStartInfo( var modulePath = Path.Combine(scriptDir, "PaletteScriptAttributes.psm1"); var usingModule = File.Exists(modulePath) ? $"using module '{modulePath}'; " : ""; + // Declared [RequiresModule(...)] dependencies are checked before the script runs, so a + // missing module fails with an actionable Install-Module hint instead of the script's + // own cryptic "term not recognized" error. Uses PowerShell's own module resolution. + var moduleCheck = BuildModuleCheck(requiredModules); + 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"; + // Import the module when present (the `using` statement must come first), run any + // required-module preflight, 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 with spaces). + var commandString = $"{usingModule}{moduleCheck}[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . '{scriptPath}' {args} 6>&1"; psi.ArgumentList.Add(commandString); if (!string.IsNullOrWhiteSpace(cwd)) @@ -212,11 +253,13 @@ public static bool RunScript( string args, string host, string? cwd, - Dictionary? env = null) + Dictionary? env = null, + bool requiresAdmin = false, + IReadOnlyList? requiredModules = null) { try { - var psi = BuildProcessStartInfo(scriptPath, args, host, cwd, env); + var psi = BuildProcessStartInfo(scriptPath, args, host, cwd, env, requiresAdmin: requiresAdmin, requiredModules: requiredModules); // Fire-and-forget: dispose the handle (this does not stop the child) so we // don't leak the Process object the caller never uses. using var proc = Process.Start(psi); @@ -244,7 +287,8 @@ public static bool RunScript( Dictionary? env = null, bool requiresAdmin = false, int? timeoutMs = null, - bool reportProgress = true) + bool reportProgress = true, + IReadOnlyList? requiredModules = null) { Process? proc = null; @@ -263,7 +307,8 @@ public static bool RunScript( cwd: cwd, env: env, requiresAdmin: requiresAdmin, - captureOutput: !requiresAdmin); + captureOutput: !requiresAdmin, + requiredModules: requiredModules); proc = Process.Start(psi); @@ -346,7 +391,8 @@ public static bool RunScript( Dictionary? env = null, bool requiresAdmin = false, int? timeoutMs = null, - bool reportProgress = true) + bool reportProgress = true, + IReadOnlyList? requiredModules = null) { Process? proc = null; @@ -380,7 +426,8 @@ public static bool RunScript( cwd: cwd, env: env, requiresAdmin: requiresAdmin, - captureOutput: !requiresAdmin); + captureOutput: !requiresAdmin, + requiredModules: requiredModules); proc = Process.Start(psi); diff --git a/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs b/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs new file mode 100644 index 0000000..0e56c86 --- /dev/null +++ b/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs @@ -0,0 +1,18 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; + +namespace PaletteShellExtension.Commands; + +/// +/// Stands in for a script's normal command when it declares elevation together with a +/// captured-output mode โ€” an impossible combination (elevated processes can't redirect +/// stdout). Selecting the row explains why instead of silently running the script unelevated. +/// +internal sealed partial class ElevationIncompatibleCommand : InvokableCommand +{ + public override string Name => "Can't run elevated with output"; + public override IconInfo Icon => new(""); // Warning + + public override CommandResult Invoke() => + CommandResult.ShowToast(ScriptElevation.IncompatibleReason()); +} diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index f492f72..62ae760 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -64,7 +64,9 @@ private CommandResult RunNow() args: "", host: host, cwd: cwd, - env: expandedEnv); + env: expandedEnv, + requiresAdmin: wantsAdmin, + requiredModules: _manifest.RequiredModules); return CommandResult.ShowToast("Script completed"); } @@ -77,7 +79,8 @@ private CommandResult RunNow() cwd: cwd, env: expandedEnv, requiresAdmin: wantsAdmin, - timeoutMs: timeout ?? PaletteShellSettingsManager.Instance.DefaultTimeoutMs); + timeoutMs: timeout ?? PaletteShellSettingsManager.Instance.DefaultTimeoutMs, + requiredModules: _manifest.RequiredModules); // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose // "View details" opens the full failure report โ€” a toast is too small and too diff --git a/PaletteShellExtension/Forms/NewScriptWizardForm.cs b/PaletteShellExtension/Forms/NewScriptWizardForm.cs index 1ef130a..0be2ed8 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.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; @@ -96,6 +97,12 @@ public NewScriptWizardForm(string root) "label": "Confirmation message (optional โ€” prompts before running)", "placeholder": "Are you sure you want to run this script?" }, + { + "type": "Input.Text", + "id": "modules", + "label": "Required modules (optional โ€” comma-separated)", + "placeholder": "ImportExcel, Az.Accounts" + }, { "type": "Input.Toggle", "id": "open", @@ -140,7 +147,8 @@ public override CommandResult SubmitForm(string inputs, string data) Host: ParseHost(formInput["host"]?.ToString()), TimeoutMs: ParseTimeout(formInput["timeout"]?.ToString()), RequiresElevation: (formInput["elevate"]?.ToString() ?? "false").Equals("true", StringComparison.OrdinalIgnoreCase), - ConfirmMessage: formInput["confirm"]?.ToString()?.Trim()); + ConfirmMessage: formInput["confirm"]?.ToString()?.Trim(), + RequiredModules: ParseModules(formInput["modules"]?.ToString())); var open = (formInput["open"]?.ToString() ?? "true").Equals("true", StringComparison.OrdinalIgnoreCase); @@ -176,6 +184,20 @@ public override CommandResult SubmitForm(string inputs, string data) : trimmed; } + // Splits the comma-separated modules field into distinct names, dropping blanks. The + // generated script emits one [RequiresModule('Name')] per entry. + private static IReadOnlyList ParseModules(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return []; + + return raw.Split(',') + .Select(m => m.Trim()) + .Where(m => m.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + // Null means "no override" โ€” the generated script omits [ScriptTimeout(...)] and falls // back to the global default timeout setting at runtime. private static int? ParseTimeout(string? raw) @@ -199,7 +221,8 @@ private readonly record struct ScriptOptions( string? Host, int? TimeoutMs, bool RequiresElevation, - string? ConfirmMessage); + string? ConfirmMessage, + IReadOnlyList RequiredModules); private static string? CreateScript(string root, string rawName, ScriptOptions options) { @@ -280,6 +303,10 @@ private static string BuildHeader(string name, ScriptOptions options) if (!string.IsNullOrWhiteSpace(options.ConfirmMessage)) sb.Append(CultureInfo.InvariantCulture, $"[ConfirmBeforeRun('{EscapeSingleQuoted(options.ConfirmMessage)}')]\n"); + // One attribute per module โ€” a missing one fails the run with an Install-Module hint. + foreach (var module in options.RequiredModules) + sb.Append(CultureInfo.InvariantCulture, $"[RequiresModule('{EscapeSingleQuoted(module)}')]\n"); + // Omitted entirely when the user didn't override it, so the script picks up the // global default timeout setting at runtime instead of freezing in today's value. if (options.TimeoutMs is { } timeoutMs) diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index 47dd9be..ab868e5 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -192,6 +192,22 @@ private CommandResult Execute(string argsLine) { try { + // Elevated scripts can't capture output, so the routing gate only lets an elevated + // script reach here when its output is None. Launch it elevated fire-and-forget + // (runas honors ArgumentList/args) โ€” there's nothing to wait for or surface. + if (ScriptElevation.RequiresElevation(_manifest)) + { + ScriptRunner.RunScript( + scriptPath: _scriptPath, + args: argsLine, + host: _host, + cwd: _cwd, + env: _env, + requiresAdmin: true, + requiredModules: _manifest.RequiredModules); + return CommandResult.ShowToast("Script completed"); + } + // Run script and wait for completion var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; var result = ScriptRunner.RunScriptAndWait( @@ -201,7 +217,8 @@ private CommandResult Execute(string argsLine) cwd: _cwd, env: _env, requiresAdmin: false, - timeoutMs: timeout); + timeoutMs: timeout, + requiredModules: _manifest.RequiredModules); // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose // "View details" opens the full failure report โ€” a toast is too small and too diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index 700fc03..c138000 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -419,6 +419,22 @@ public override IListItem[] GetItems() return; } + // Elevation can't capture output, so an elevated script declared with any + // capturing output mode is impossible. Block it with a warning row (rather than + // let a route run it unelevated) โ€” this one gate covers every route below. + if (manifest is not null && ScriptElevation.IsElevatedOutputIncompatible(manifest)) + { + var elevationPinned = pins.IsPinned(path); + scriptResults[i] = (elevationPinned, title, new ListItem(new ElevationIncompatibleCommand()) + { + Title = title, + Subtitle = ScriptElevation.IncompatibleReason(), + Icon = new IconInfo(""), // Warning + MoreCommands = BuildContextCommands(path, pins) + }); + return; + } + var wantsMarkdown = string.Equals(manifest?.Output, "Markdown", StringComparison.OrdinalIgnoreCase); var wantsList = string.Equals(manifest?.Output, "List", StringComparison.OrdinalIgnoreCase); var wantsResult = string.Equals(manifest?.Output, "Result", StringComparison.OrdinalIgnoreCase); diff --git a/PaletteShellExtension/Pages/ScriptListPage.cs b/PaletteShellExtension/Pages/ScriptListPage.cs index 066b95e..f6e4f46 100644 --- a/PaletteShellExtension/Pages/ScriptListPage.cs +++ b/PaletteShellExtension/Pages/ScriptListPage.cs @@ -164,7 +164,8 @@ private async Task ExecuteAsync(string? query, CancellationToken cancellationTok cwd: _cwd, env: _env, requiresAdmin: false, - timeoutMs: timeout); + timeoutMs: timeout, + requiredModules: _manifest.RequiredModules); if (cancellationToken.IsCancellationRequested) { diff --git a/PaletteShellExtension/Pages/ScriptMarkdownPage.cs b/PaletteShellExtension/Pages/ScriptMarkdownPage.cs index bd6015b..1c97128 100644 --- a/PaletteShellExtension/Pages/ScriptMarkdownPage.cs +++ b/PaletteShellExtension/Pages/ScriptMarkdownPage.cs @@ -80,7 +80,8 @@ private async Task RunAndRender() cwd: _cwd, env: _env, requiresAdmin: false, - timeoutMs: timeout); + timeoutMs: timeout, + requiredModules: _manifest.RequiredModules); _content.Body = FormatResult(result); } diff --git a/PaletteShellExtension/Pages/ScriptResultPage.cs b/PaletteShellExtension/Pages/ScriptResultPage.cs index fa45a6b..d808266 100644 --- a/PaletteShellExtension/Pages/ScriptResultPage.cs +++ b/PaletteShellExtension/Pages/ScriptResultPage.cs @@ -94,7 +94,8 @@ private async Task Execute() cwd: _cwd, env: _env, requiresAdmin: false, - timeoutMs: timeout); + timeoutMs: timeout, + requiredModules: _manifest.RequiredModules); _items = BuildItems(result); } diff --git a/PaletteShellExtension/PaletteScriptAttributes.psm1 b/PaletteShellExtension/PaletteScriptAttributes.psm1 index 511cd42..f9aa289 100644 --- a/PaletteShellExtension/PaletteScriptAttributes.psm1 +++ b/PaletteShellExtension/PaletteScriptAttributes.psm1 @@ -66,6 +66,15 @@ class ScriptVersionAttribute : Attribute { ScriptVersionAttribute([string]$version) { $this.Version = $version } } +# Requires a PowerShell module be installed to run this script. Repeat the attribute for +# multiple modules, e.g. [RequiresModule('ImportExcel')] [RequiresModule('Az.Accounts')]. +# When the script is run, PaletteShell checks each module and fails with an Install-Module +# hint if it isn't available. +class RequiresModuleAttribute : Attribute { + [string]$Name + RequiresModuleAttribute([string]$name) { $this.Name = $name } +} + # Minimum PaletteShell app version (SemVer) required to run this script. PaletteShell hides # the script (with an explanatory row) instead of running it when the installed app is older. class RequiresPaletteShellMinimumAttribute : Attribute { diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index efefc89..a7bb4cb 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -363,6 +363,9 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) case "ScriptEnv" when values.Count >= 2: manifest.Env[values[0]] = values[1]; break; + case "RequiresModule" when values.Count >= 1 && !string.IsNullOrWhiteSpace(values[0]): + manifest.RequiredModules.Add(values[0]); + break; } } } @@ -545,7 +548,7 @@ private static string MapType(string psType, bool hasValidateSet) "PSDefaultValue", "ArgumentCompleter", "ScriptHost", "ScriptCwd", "RequiresElevation", "ConfirmBeforeRun", "ScriptTimeout", "ScriptOutput", "ScriptIcon", "ScriptGroup", "ScriptTags", "ScriptEnv", "ScriptVersion", - "RequiresPaletteShellMinimum", "RequiresPaletteShellMaximum" + "RequiresModule", "RequiresPaletteShellMinimum", "RequiresPaletteShellMaximum" }; /// Removes <# ... #> blocks and whole-line # comments. diff --git a/README.md b/README.md index 05f6fcf..1a6dbe0 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Whether PaletteShell waits for the script depends on its output mode and timeout | `[ScriptOutput('Open')]` | The first non-empty output line is opened as a URL, file, or folder path. | | `[ScriptOutput('File')]` | Captured output is written to a temp file and opened in your editor. | | `[ConfirmBeforeRun('msg')]` | Selecting the script prompts a yes/no dialog before it runs. | -| `[RequiresElevation()]` / `#Requires -RunAsAdministrator` | The process is launched elevated (`runas`); output capture is unavailable in this mode. | +| `[RequiresElevation()]` / `#Requires -RunAsAdministrator` | The process is launched elevated (`runas`); output capture is unavailable in this mode, so elevation is only compatible with `[ScriptOutput('None')]`. Combined with any other output mode the script is shown as an incompatible row instead of running. | ### Cross-platform clipboard From 27d733d06d8ec736800c7de7d5794f0821f24679 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:13:37 -0400 Subject: [PATCH 08/21] quoting fixes --- .../ScriptRunnerTests.cs | 77 +++++++++++++++++++ .../Classes/PowerShellQuoting.cs | 11 +++ PaletteShellExtension/Classes/ScriptRunner.cs | 6 +- .../Forms/ScriptParameterForm.cs | 2 +- PaletteShellExtension/Pages/ScriptListPage.cs | 2 +- 5 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 PaletteShellExtension.Tests/ScriptRunnerTests.cs create mode 100644 PaletteShellExtension/Classes/PowerShellQuoting.cs diff --git a/PaletteShellExtension.Tests/ScriptRunnerTests.cs b/PaletteShellExtension.Tests/ScriptRunnerTests.cs new file mode 100644 index 0000000..d654b05 --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptRunnerTests.cs @@ -0,0 +1,77 @@ +using System.IO; +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptRunnerTests +{ + private static string CommandString(System.Diagnostics.ProcessStartInfo psi) => + psi.ArgumentList[psi.ArgumentList.Count - 1]; + + [Theory] + [InlineData("a'b", "'a''b'")] + [InlineData("plain", "'plain'")] + [InlineData("", "''")] + [InlineData(null, "''")] + [InlineData("O'Brien's", "'O''Brien''s'")] + public void SingleQuote_DoublesEmbeddedQuotes(string? input, string expected) + { + Assert.Equal(expected, PowerShellQuoting.SingleQuote(input)); + } + + [Fact] + public void BuildProcessStartInfo_EscapesApostropheInScriptPath() + { + var psi = ScriptRunner.BuildProcessStartInfo( + scriptPath: @"C:\Users\Sean's PC\s.ps1", + args: "", + host: "powershell", + cwd: null); + + var cmd = CommandString(psi); + Assert.Contains(@". 'C:\Users\Sean''s PC\s.ps1'", cmd); + } + + [Fact] + public void BuildProcessStartInfo_NormalPath_SingleQuotedOnce() + { + var psi = ScriptRunner.BuildProcessStartInfo( + scriptPath: @"C:\scripts\test.ps1", + args: "", + host: "powershell", + cwd: null); + + var cmd = CommandString(psi); + Assert.Contains(@". 'C:\scripts\test.ps1'", cmd); + Assert.DoesNotContain("''", cmd); + } + + [Fact] + public void BuildProcessStartInfo_EscapesApostropheInModulePath() + { + // Module segment only emitted when the .psm1 exists next to the script, so use a temp + // dir with an apostrophe in the path and drop the module file there. + var dir = Path.Combine(Path.GetTempPath(), "PSE'Test_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "PaletteScriptAttributes.psm1"), ""); + var scriptPath = Path.Combine(dir, "s.ps1"); + + var psi = ScriptRunner.BuildProcessStartInfo( + scriptPath: scriptPath, + args: "", + host: "powershell", + cwd: null); + + var cmd = CommandString(psi); + var expectedModule = Path.Combine(dir, "PaletteScriptAttributes.psm1").Replace("'", "''"); + Assert.Contains($"using module '{expectedModule}';", cmd); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/PaletteShellExtension/Classes/PowerShellQuoting.cs b/PaletteShellExtension/Classes/PowerShellQuoting.cs new file mode 100644 index 0000000..9c14fff --- /dev/null +++ b/PaletteShellExtension/Classes/PowerShellQuoting.cs @@ -0,0 +1,11 @@ +namespace PaletteShellExtension.Classes; + +internal static class PowerShellQuoting +{ + /// + /// Wraps a value as a PowerShell single-quoted string literal, doubling embedded + /// quotes so it can't break out of the string or inject commands. + /// + public static string SingleQuote(string? value) + => "'" + (value ?? "").Replace("'", "''") + "'"; +} diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index 5d51b5b..59a3e0e 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -156,7 +156,7 @@ private static string BuildModuleCheck(IReadOnlyList? requiredModules) { continue; } - quoted.Add("'" + name.Replace("'", "''") + "'"); + quoted.Add(PowerShellQuoting.SingleQuote(name)); } if (quoted.Count == 0) @@ -193,7 +193,7 @@ 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"); - var usingModule = File.Exists(modulePath) ? $"using module '{modulePath}'; " : ""; + var usingModule = File.Exists(modulePath) ? $"using module {PowerShellQuoting.SingleQuote(modulePath)}; " : ""; // Declared [RequiresModule(...)] dependencies are checked before the script runs, so a // missing module fails with an actionable Install-Module hint instead of the script's @@ -207,7 +207,7 @@ public static ProcessStartInfo BuildProcessStartInfo( // 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 with spaces). - var commandString = $"{usingModule}{moduleCheck}[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . '{scriptPath}' {args} 6>&1"; + var commandString = $"{usingModule}{moduleCheck}[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . {PowerShellQuoting.SingleQuote(scriptPath)} {args} 6>&1"; psi.ArgumentList.Add(commandString); if (!string.IsNullOrWhiteSpace(cwd)) diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index ab868e5..4070370 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -418,6 +418,6 @@ private static string FormatArgValue(ScriptParameter param, string value) if (param.AllowExpression) return value; - return "'" + value.Replace("'", "''") + "'"; + return PowerShellQuoting.SingleQuote(value); } } diff --git a/PaletteShellExtension/Pages/ScriptListPage.cs b/PaletteShellExtension/Pages/ScriptListPage.cs index f6e4f46..fee67cc 100644 --- a/PaletteShellExtension/Pages/ScriptListPage.cs +++ b/PaletteShellExtension/Pages/ScriptListPage.cs @@ -201,7 +201,7 @@ private string BuildArgs(string? query) } // Single-quote the literal so paths and spaces reach the script intact. - return $"-{_queryParam} '{query.Replace("'", "''")}'"; + return $"-{_queryParam} {PowerShellQuoting.SingleQuote(query)}"; } private static IListItem[] Filter(IListItem[] items, string? search) From bcf2021fb687a75892a5b1aff7aa0bac1daac8f0 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:18:36 -0400 Subject: [PATCH 09/21] Do not background copy the modules during initialize --- PaletteShellExtension/Pages/PaletteShellExtensionPage.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index c138000..8e778ba 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -87,12 +87,8 @@ private void InitializeFolder(string folder) Directory.CreateDirectory(folder); _pins = new PinnedScripts(folder); CopySampleScripts(folder); + CopyPowerShellModule(folder); RefreshFiles(); - - // The module/docs/dll files copied here are never *.ps1 files, so RefreshFiles() - // never picks them up and they can't affect what GetItems() shows โ€” safe to push - // off the constructor's critical path instead of blocking the first render on them. - _ = Task.Run(() => CopyPowerShellModule(folder)); } // Called by the setup form once the user chooses a folder for the first time. The form @@ -143,7 +139,7 @@ public int RefreshFiles() Directory.CreateDirectory(configuredFolder); _pins = new PinnedScripts(configuredFolder); CopySampleScripts(configuredFolder); - _ = Task.Run(() => CopyPowerShellModule(configuredFolder)); + CopyPowerShellModule(configuredFolder); } var rootDirectory = _rootDirectory; From dc50b00a6c961b2f8b35fd26d5364c66b8ef5028 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:23:24 -0400 Subject: [PATCH 10/21] enforce script timeout --- PaletteShellExtension/Classes/ScriptRunner.cs | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index 59a3e0e..d359708 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -4,6 +4,7 @@ using System.IO; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; namespace PaletteShellExtension.Classes; @@ -288,10 +289,17 @@ public static bool RunScript( bool requiresAdmin = false, int? timeoutMs = null, bool reportProgress = true, - IReadOnlyList? requiredModules = null) + IReadOnlyList? requiredModules = null, + CancellationToken cancellationToken = default) { Process? proc = null; + // Same hard ceiling as the synchronous runner: a null or oversized timeout is + // clamped to MaxTimeoutMs so no async path can wait forever. + var effectiveTimeoutMs = Math.Min( + timeoutMs ?? PowerShellScriptParser.MaxTimeoutMs, + PowerShellScriptParser.MaxTimeoutMs); + var stopwatch = Stopwatch.StartNew(); var progress = reportProgress @@ -330,33 +338,39 @@ public static bool RunScript( stderrTask = proc.StandardError.ReadToEndAsync(); } - if (timeoutMs.HasValue) + try { - try - { - await proc.WaitForExitAsync().WaitAsync(TimeSpan.FromMilliseconds(timeoutMs.Value)).ConfigureAwait(false); - } - catch (TimeoutException) + await proc.WaitForExitAsync() + .WaitAsync(TimeSpan.FromMilliseconds(effectiveTimeoutMs), cancellationToken) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + result.TimedOut = true; + result.DurationMs = stopwatch.ElapsedMilliseconds; + try { proc.Kill(entireProcessTree: true); } + catch (Exception) { - result.TimedOut = true; - result.DurationMs = stopwatch.ElapsedMilliseconds; - try { proc.Kill(entireProcessTree: true); } - catch (Exception) - { - // Ignore failures killing the process tree. - } - // Killing closes the pipes, so the reads complete with whatever was - // buffered; capture that partial output before returning. The Warn comes - // after the drain so it can include the script's stderr. - result.StandardOutput = await AwaitReadAsync(stdoutTask).ConfigureAwait(false); - result.StandardError = await AwaitReadAsync(stderrTask).ConfigureAwait(false); - Log.Warn($"Script '{scriptPath}' timed out after {timeoutMs.Value}ms and was killed{ScriptFailureReport.StderrForLog(result.StandardError)}"); - return result; + // Ignore failures killing the process tree. } + // Killing closes the pipes, so the reads complete with whatever was + // buffered; capture that partial output before returning. The Warn comes + // after the drain so it can include the script's stderr. + result.StandardOutput = await AwaitReadAsync(stdoutTask).ConfigureAwait(false); + result.StandardError = await AwaitReadAsync(stderrTask).ConfigureAwait(false); + Log.Warn($"Script '{scriptPath}' timed out after {stopwatch.ElapsedMilliseconds}ms (limit {effectiveTimeoutMs}ms) and was killed{ScriptFailureReport.StderrForLog(result.StandardError)}"); + return result; } - else + catch (OperationCanceledException) { - await proc.WaitForExitAsync().ConfigureAwait(false); + // Caller cancelled (e.g. a live-provider page superseded this run). Kill the + // orphaned child so it isn't left running, then propagate the cancellation. + try { proc.Kill(entireProcessTree: true); } + catch (Exception) + { + // Ignore failures killing the process tree. + } + throw; } // The process has exited, so the streams are closed and the reads finish From 8fe9b85802d25d4b07b0754a70608115865651bb Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:28:41 -0400 Subject: [PATCH 11/21] remove script version stamper --- .../Classes/ScriptVersionStamper.cs | 104 ------------------ .../Pages/PaletteShellExtensionPage.cs | 14 +-- 2 files changed, 1 insertion(+), 117 deletions(-) delete mode 100644 PaletteShellExtension/Classes/ScriptVersionStamper.cs diff --git a/PaletteShellExtension/Classes/ScriptVersionStamper.cs b/PaletteShellExtension/Classes/ScriptVersionStamper.cs deleted file mode 100644 index 4d644d0..0000000 --- a/PaletteShellExtension/Classes/ScriptVersionStamper.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; - -namespace PaletteShellExtension.Classes; - -/// -/// Backfills a [ScriptVersion('1.0.0')] attribute into .ps1 scripts that don't declare -/// one, so every script PaletteShell manages carries a version rather than relying on the -/// parser's in-memory default. Runs opportunistically during the folder scan. -/// -/// -/// This mutates the user's own files, so every operation is conservative and reversible-by-default: -/// -/// It only ever adds the attribute, and only when none is present (idempotent). -/// It skips a script with no top-level param(...) block - there's no safe, valid place -/// to attach a bare attribute in that case, and a wrong insertion could break the script. -/// It preserves the file's existing newline style and UTF-8 BOM (or lack of one), and skips -/// UTF-16 files entirely rather than risk re-encoding them. -/// The write is atomic (temp file + move), and any failure is swallowed - a scan must never -/// fail, or leave a half-written script, over a best-effort version stamp. -/// -/// -internal static partial class ScriptVersionStamper -{ - private const string DefaultVersion = "1.0.0"; - - /// Adds [ScriptVersion('1.0.0')] to the script at if - /// it declares no version. Returns true only when the file was actually rewritten. - public static bool TryStamp(string ps1Path) - { - try - { - var bytes = File.ReadAllBytes(ps1Path); - - // A UTF-16 BOM means re-encoding as UTF-8 (what we'd write) would corrupt the file, and - // the rest of PaletteShell reads scripts as UTF-8 anyway - leave these well alone. - if (HasUtf16Bom(bytes)) - { - return false; - } - - var hasBom = HasUtf8Bom(bytes); - var bomLength = hasBom ? 3 : 0; - var content = Encoding.UTF8.GetString(bytes, bomLength, bytes.Length - bomLength); - - if (HasScriptVersion(content)) - { - return false; - } - - // Attach the attribute to the top-level param block - the one position that's both a - // valid PowerShell attribute target and where the parser (and every scaffolded script) - // already puts the [Script*] attributes. No param block: nothing safe to attach to. - var param = ParamBlockRegex().Match(content); - if (!param.Success) - { - return false; - } - - var newline = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; - var insertion = $"[ScriptVersion('{DefaultVersion}')]{newline}"; - var stamped = content.Insert(param.Index, insertion); - - WriteAtomic(ps1Path, stamped, hasBom); - return true; - } - catch (Exception ex) - { - // Locked file, permissions, a torn read - none of it is worth failing the scan over. - Log.Warn($"Couldn't stamp ScriptVersion into '{ps1Path}': {ex.Message}"); - return false; - } - } - - private static bool HasUtf8Bom(byte[] b) => - b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF; - - private static bool HasUtf16Bom(byte[] b) => - b.Length >= 2 && ((b[0] == 0xFF && b[1] == 0xFE) || (b[0] == 0xFE && b[1] == 0xFF)); - - private static bool HasScriptVersion(string content) => ScriptVersionRegex().IsMatch(content); - - private static void WriteAtomic(string path, string content, bool withBom) - { - var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: withBom); - var tempPath = path + ".psver.tmp"; - File.WriteAllText(tempPath, content, encoding); - File.Move(tempPath, path, overwrite: true); - } - - // A [ScriptVersion ...] attribute anywhere - conservative: if the token appears at all we treat - // the script as already versioned and leave it untouched, even if it's only in a comment. - [GeneratedRegex(@"\[\s*ScriptVersion\b", RegexOptions.IgnoreCase)] - private static partial Regex ScriptVersionRegex(); - - // A top-level param block at the start of a line, allowing inline attributes (e.g. - // "[CmdletBinding()] param("). Anchoring to line start keeps this from matching a stray - // "param(" buried in a comment or string. The match starts at column 0 so the stamp is - // inserted on its own line immediately above. - [GeneratedRegex(@"(?im)^[ \t]*(?:\[[^\]\r\n]*\][ \t]*)*param[ \t]*\(")] - private static partial Regex ParamBlockRegex(); -} diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index 8e778ba..5920560 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -149,19 +149,7 @@ public int RefreshFiles() // instead of paying a second stat per script (noticeable on synced/network folders). var discovered = new DirectoryInfo(rootDirectory).EnumerateFiles("*.ps1", SearchOption.TopDirectoryOnly).ToList(); - // Backfill a [ScriptVersion('1.0.0')] into any script that declares no version. - // Idempotent and best-effort (see ScriptVersionStamper), so an already-stamped folder - // just pays a cheap read per file. Refresh the FileInfo of anything actually rewritten - // so its size/write-time - which the manifest cache keys on - reflects the new content. - foreach (var file in discovered) - { - if (ScriptVersionStamper.TryStamp(file.FullName)) - { - file.Refresh(); - } - } - - _files = [.. discovered]; + _files = [.. discovered]; PruneManifestCache(_files.Select(f => f.FullName)); _folderError = null; } From 6f6b8671cd0e5f232d4d8aa62b582b24658565aa Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:35:23 -0400 Subject: [PATCH 12/21] Better handling around opening links --- .../Classes/EditorLauncher.cs | 11 +++- .../Classes/ScriptOutputHandler.cs | 62 ++++++++++++++++--- .../Commands/OpenFolderCommand.cs | 14 ++++- .../Commands/OpenLinkCommand.cs | 14 ++++- .../Commands/RevealInExplorerCommand.cs | 12 +++- 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/PaletteShellExtension/Classes/EditorLauncher.cs b/PaletteShellExtension/Classes/EditorLauncher.cs index c7bb103..b7e5b2c 100644 --- a/PaletteShellExtension/Classes/EditorLauncher.cs +++ b/PaletteShellExtension/Classes/EditorLauncher.cs @@ -22,7 +22,16 @@ public static void Open(string path) ?? Environment.GetEnvironmentVariable("VISUAL") ?? Environment.GetEnvironmentVariable("EDITOR") ?? "notepad.exe"; - Process.Start(new ProcessStartInfo(editor, $"\"{path}\"") { UseShellExecute = true }); + try + { + Process.Start(new ProcessStartInfo(editor, $"\"{path}\"") { UseShellExecute = true }); + } + catch (Exception ex) + { + // A misconfigured $EDITOR/$VISUAL or a missing handler must not throw through a + // Command Palette invocation. Log and give up quietly โ€” the temp file still exists. + Log.Warn($"Failed to open '{path}' in editor '{editor}': {ex.Message}"); + } } /// diff --git a/PaletteShellExtension/Classes/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs index 8e1cabf..f833e58 100644 --- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs +++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs @@ -1,6 +1,8 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Commands; using System; using System.Diagnostics; +using System.IO; using System.Linq; namespace PaletteShellExtension.Classes; @@ -34,16 +36,22 @@ public static CommandResult ToResult(string? mode, string? output, string? fileE return CommandResult.ShowToast("Script completed without an open target"); } - try + // Only http(s) URLs and existing files/folders open without a prompt. Anything + // else โ€” custom protocols (ms-settings:, shell:), file://, .lnk shortcuts, + // unknown targets โ€” launches arbitrary handlers, so confirm with the exact + // target shown before letting Windows resolve it. + if (IsSafeOpenTarget(target)) { - Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); - return CommandResult.ShowToast("Opened script output"); + return OpenTarget(target); } - catch (Exception ex) + + return CommandResult.Confirm(new ConfirmationArgs { - Log.Warn($"Failed to open script output target '{target}': {ex.Message}"); - return CommandResult.ShowToast($"Couldn't open script output: {ex.Message}"); - } + Title = "Open script output?", + Description = $"This script wants to open:\n\n{target}\n\nThis isn't a web link or a file on disk โ€” it may launch another app or system handler. Open it?", + PrimaryCommand = new CallbackCommand("Open", () => OpenTarget(target)), + IsPrimaryCommandCritical = true, + }); // Write stdout to a temp file and open it in the user's editor. Useful for // output that's too large or structured to be readable in a toast. @@ -67,6 +75,46 @@ public static CommandResult ToResult(string? mode, string? output, string? fileE } } + private static CommandResult OpenTarget(string target) + { + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + return CommandResult.ShowToast("Opened script output"); + } + catch (Exception ex) + { + Log.Warn($"Failed to open script output target '{target}': {ex.Message}"); + return CommandResult.ShowToast($"Couldn't open script output: {ex.Message}"); + } + } + + // Open without prompting only for web links and real files/folders on disk. .lnk + // shortcuts are excluded even when they exist: launching one runs whatever it points at. + internal static bool IsSafeOpenTarget(string target) + { + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return true; + } + + if (target.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + try + { + return File.Exists(target) || Directory.Exists(target); + } + catch + { + // Malformed path (bad chars, too long) โ€” treat as unsafe and let the confirm show it. + return false; + } + } + internal static string? GetOpenTarget(string? output) { if (string.IsNullOrWhiteSpace(output)) diff --git a/PaletteShellExtension/Commands/OpenFolderCommand.cs b/PaletteShellExtension/Commands/OpenFolderCommand.cs index 132688d..0cf3488 100644 --- a/PaletteShellExtension/Commands/OpenFolderCommand.cs +++ b/PaletteShellExtension/Commands/OpenFolderCommand.cs @@ -1,4 +1,6 @@ ๏ปฟusing Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using System; using System.Diagnostics; namespace PaletteShellExtension.Commands; @@ -9,7 +11,15 @@ internal sealed partial class OpenFolderCommand(string folder, string name = "Op public override IconInfo Icon => new("\uE8B7"); public override CommandResult Invoke() { - Process.Start(new ProcessStartInfo("explorer.exe", $"\"{folder}\"") { UseShellExecute = true }); - return CommandResult.Dismiss(); + try + { + Process.Start(new ProcessStartInfo("explorer.exe", $"\"{folder}\"") { UseShellExecute = true }); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + Log.Warn($"Failed to open folder '{folder}': {ex.Message}"); + return CommandResult.ShowToast($"Couldn't open folder: {ex.Message}"); + } } } diff --git a/PaletteShellExtension/Commands/OpenLinkCommand.cs b/PaletteShellExtension/Commands/OpenLinkCommand.cs index 7355bec..e78de1a 100644 --- a/PaletteShellExtension/Commands/OpenLinkCommand.cs +++ b/PaletteShellExtension/Commands/OpenLinkCommand.cs @@ -1,4 +1,6 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using System; using System.Diagnostics; namespace PaletteShellExtension.Commands; @@ -10,7 +12,15 @@ internal sealed partial class OpenLinkCommand(string name, string url, string ic public override CommandResult Invoke() { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - return CommandResult.Dismiss(); + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + Log.Warn($"Failed to open link '{url}': {ex.Message}"); + return CommandResult.ShowToast($"Couldn't open link: {ex.Message}"); + } } } diff --git a/PaletteShellExtension/Commands/RevealInExplorerCommand.cs b/PaletteShellExtension/Commands/RevealInExplorerCommand.cs index d125350..03fcc76 100644 --- a/PaletteShellExtension/Commands/RevealInExplorerCommand.cs +++ b/PaletteShellExtension/Commands/RevealInExplorerCommand.cs @@ -1,4 +1,6 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using System; using System.Diagnostics; using System.IO; @@ -17,7 +19,15 @@ public override CommandResult Invoke() { if (File.Exists(path)) { - Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true }); + try + { + Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true }); + } + catch (Exception ex) + { + Log.Warn($"Failed to reveal '{path}' in Explorer: {ex.Message}"); + return CommandResult.ShowToast($"Couldn't reveal in File Explorer: {ex.Message}"); + } } return CommandResult.Dismiss(); From 311ea828fa9d9019313b9ab08448188e08134b26 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:44:10 -0400 Subject: [PATCH 13/21] redact any sensitive arguments --- .../Classes/EditorLauncher.cs | 45 +++++++++++++++- .../Classes/PaletteShellSettingsManager.cs | 51 +++++++++++++++++++ .../Classes/ScriptFailureReport.cs | 19 ++++++- .../Commands/ScriptFailurePresenter.cs | 1 + 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/PaletteShellExtension/Classes/EditorLauncher.cs b/PaletteShellExtension/Classes/EditorLauncher.cs index b7e5b2c..73d5b9d 100644 --- a/PaletteShellExtension/Classes/EditorLauncher.cs +++ b/PaletteShellExtension/Classes/EditorLauncher.cs @@ -16,6 +16,10 @@ internal static class EditorLauncher // reliably detect UTF-8 instead of guessing at all-non-Latin content. private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true); + /// Temp folder holding failure reports and "File" output-mode results. Exposed so + /// the palette can offer an "Open report folder" command and so cleanup has one source of truth. + internal static string OutputDirectory => Path.Combine(Path.GetTempPath(), "PaletteShell"); + public static void Open(string path) { var editor = PaletteShellSettingsManager.Instance.PreferredEditor @@ -42,8 +46,9 @@ public static void Open(string path) /// public static string OpenContent(string content, string? extension = null, string? baseName = null) { - var dir = Path.Combine(Path.GetTempPath(), "PaletteShell"); + var dir = OutputDirectory; Directory.CreateDirectory(dir); + PruneOld(dir); var name = Sanitize(baseName) ?? "output"; var fileName = $"{name}-{DateTime.Now:yyyyMMdd-HHmmss}{NormalizeExtension(extension)}"; @@ -54,6 +59,44 @@ public static string OpenContent(string content, string? extension = null, strin return path; } + /// + /// Deletes files in the temp folder older than the configured retention window โ€” these + /// reports/outputs can carry sensitive script data, so they shouldn't accumulate forever. + /// Gated by the user's cleanup setting and entirely best-effort: neither a disabled + /// setting nor a locked file may break the write that triggered it. + /// + private static void PruneOld(string dir) + { + try + { + var settings = PaletteShellSettingsManager.Instance; + if (!settings.CleanupTempEnabled) + { + return; + } + + var cutoff = DateTime.Now.AddDays(-settings.CleanupRetentionDays); + foreach (var file in Directory.GetFiles(dir)) + { + try + { + if (File.GetLastWriteTime(file) < cutoff) + { + File.Delete(file); + } + } + catch (Exception) + { + // A locked or already-removed file just gets picked up next time. + } + } + } + catch (Exception) + { + // Cleanup is a convenience; never let it break opening the report/output. + } + } + private static string NormalizeExtension(string? extension) { if (string.IsNullOrWhiteSpace(extension)) diff --git a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs index 7673c54..0db9c48 100644 --- a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs +++ b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs @@ -21,6 +21,13 @@ internal sealed class PaletteShellSettingsManager : JsonSettingsManager // [ScriptTimeout], and the user hasn't overridden the default (or entered garbage). private const int FallbackTimeoutMs = 30000; + // Retention window for the %TEMP%\PaletteShell dir (failure reports + File-mode output). + // Defaulted/clamped the same way the timeout is, so a typo'd setting can't disable + // cleanup outright (0) or set an absurd window. + private const int DefaultCleanupDays = 7; + private const int MinCleanupDays = 1; + private const int MaxCleanupDays = 365; + public static PaletteShellSettingsManager Instance { get; } = new(); private readonly ChoiceSetSetting _defaultHost = new( @@ -52,6 +59,18 @@ internal sealed class PaletteShellSettingsManager : JsonSettingsManager "Folder PaletteShell scans for .ps1 scripts. Changing this doesn't move existing scripts or pins โ€” run \"Reload scripts\" afterward to pick it up.", string.Empty); + private readonly ToggleSetting _cleanupTempEnabled = new( + "cleanupTempEnabled", + "Auto-clean temp reports/output", + "Periodically delete old failure reports and \"File\" output-mode results from the temp folder (%TEMP%\\PaletteShell). These can contain script output and arguments.", + true); + + private readonly TextSetting _cleanupTempDays = new( + "cleanupTempDays", + "Delete temp files older than (days)", + "How long to keep files in %TEMP%\\PaletteShell before auto-clean removes them.", + DefaultCleanupDays.ToString(CultureInfo.InvariantCulture)); + /// The configured default host: "auto", "pwsh", or "powershell". public string DefaultHost => _defaultHost.Value ?? HostAuto; @@ -87,6 +106,36 @@ public int DefaultTimeoutMs public string? PreferredEditor => string.IsNullOrWhiteSpace(_preferredEditor.Value) ? null : _preferredEditor.Value.Trim(); + /// Whether the %TEMP%\PaletteShell dir is auto-cleaned when a new report/output is written. + public bool CleanupTempEnabled => _cleanupTempEnabled.Value; + + /// Retention window (days) for %TEMP%\PaletteShell, clamped like + /// so a typo can't disable cleanup (0) or set an absurd window. + public int CleanupRetentionDays + { + get + { + if (!int.TryParse(_cleanupTempDays.Value, out var days) || days <= 0) + { + return DefaultCleanupDays; + } + + if (days < MinCleanupDays) + { + Log.Warn($"Clamping cleanup retention setting ({days}) to the {MinCleanupDays}-day minimum"); + return MinCleanupDays; + } + + if (days > MaxCleanupDays) + { + Log.Warn($"Clamping cleanup retention setting ({days}) to the {MaxCleanupDays}-day maximum"); + return MaxCleanupDays; + } + + return days; + } + } + /// The configured scripts folder, or null when the user hasn't chosen one yet. public string? ScriptsFolder { @@ -119,6 +168,8 @@ private PaletteShellSettingsManager() Settings.Add(_defaultTimeoutMs); Settings.Add(_preferredEditor); Settings.Add(_scriptsFolder); + Settings.Add(_cleanupTempEnabled); + Settings.Add(_cleanupTempDays); try { diff --git a/PaletteShellExtension/Classes/ScriptFailureReport.cs b/PaletteShellExtension/Classes/ScriptFailureReport.cs index c7c5ee4..7acf0be 100644 --- a/PaletteShellExtension/Classes/ScriptFailureReport.cs +++ b/PaletteShellExtension/Classes/ScriptFailureReport.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.IO; using System.Text; +using System.Text.RegularExpressions; namespace PaletteShellExtension.Classes; @@ -13,6 +14,21 @@ namespace PaletteShellExtension.Classes; /// internal static class ScriptFailureReport { + // Matches a PowerShell switch whose name contains a secret-ish keyword, plus the token + // that follows it โ€” a single-quoted value ('' = escaped quote) or a bare token (numbers, + // bools, expressions). The value is masked so tokens/passwords/keys don't land in the + // plaintext report. `key` is deliberately broad (also matches -Keyword etc.): over- + // redaction is harmless here, under-redaction leaks secrets. + private static readonly Regex SensitiveArg = new( + @"-(\w*(?:password|token|secret|credential|apikey|pwd|key)\w*)\s+('(?:[^']|'')*'|\S+)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// Masks values of arguments whose switch name looks like a secret + /// (password/token/secret/credential/apikey/pwd/key), leaving the switch name intact: + /// -Token 'abc' becomes -Token '***'. + internal static string RedactArgs(string args) => + SensitiveArg.Replace(args, "-$1 '***'"); + /// One-line outcome, e.g. "exited with code 1", "timed out and was killed", /// "failed to start". Shared by the report header and the failure dialog title. public static string DescribeOutcome(ScriptRunner.ScriptResult? result) @@ -41,7 +57,7 @@ public static string Build(string scriptPath, string host, string args, ScriptRu sb.AppendLine(); sb.AppendLine(culture, $"Script: {scriptPath}"); sb.AppendLine(culture, $"Shell: {ScriptRunner.ResolveShell(host)}"); - sb.AppendLine(culture, $"Args: {(string.IsNullOrWhiteSpace(args) ? "(none)" : args)}"); + sb.AppendLine(culture, $"Args: {(string.IsNullOrWhiteSpace(args) ? "(none)" : RedactArgs(args))}"); sb.AppendLine(culture, $"Outcome: {DescribeOutcome(result)}"); if (result?.DurationMs is { } duration) { @@ -55,6 +71,7 @@ public static string Build(string scriptPath, string host, string args, ScriptRu sb.AppendLine(ContentOrPlaceholder(result?.StandardOutput)); sb.AppendLine(); sb.AppendLine(culture, $"Logs: {Log.LogDirectory}"); + sb.AppendLine(culture, $"This report: {EditorLauncher.OutputDirectory} (auto-cleaned when enabled in settings)"); return sb.ToString(); } diff --git a/PaletteShellExtension/Commands/ScriptFailurePresenter.cs b/PaletteShellExtension/Commands/ScriptFailurePresenter.cs index dfc9830..f5d59ba 100644 --- a/PaletteShellExtension/Commands/ScriptFailurePresenter.cs +++ b/PaletteShellExtension/Commands/ScriptFailurePresenter.cs @@ -52,6 +52,7 @@ public static ListItem ToListItem(string scriptPath, string host, string args, S Icon = new IconInfo("๎žบ"), // Warning MoreCommands = [ + new CommandContextItem(new OpenFolderCommand(EditorLauncher.OutputDirectory, "Open report folder")), new CommandContextItem(new OpenFolderCommand(Log.LogDirectory, "Open log folder")), ], }; From ecd8e0f568787c0aeacdf901f3ff203ac10641d1 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:49:41 -0400 Subject: [PATCH 14/21] updating documentation --- AGENTS.md | 2 +- README.md | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2567e57..b331d76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything e | `[ScriptCwd('{ScriptDir}')]` | Working directory (supports path tokens, below) | | `[ScriptGroup('Category')]` | Group name used by tooling such as the Script Manager catalog browser | | `[ScriptTags('foo,bar,baz')]` | Comma-delimited free-form tags used by tooling such as the Script Manager catalog browser | -| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) โ€” lets tools detect when a newer copy is available. A script that omits it is stamped with `1.0.0` on load | +| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) โ€” lets tools detect when a newer copy is available. A script that omits it is treated as `1.0.0` (the file is not rewritten) | | `[RequiresPaletteShellMinimum('1.2.0')]` | Minimum PaletteShell app version required. If the installed app is older, the row shows "Requires an update" instead of running the script. Defaults to `0.0.6` (the last release before this attribute existed) when omitted | | `[RequiresPaletteShellMaximum('2.0.0')]` | Maximum PaletteShell app version this script still works on. If the installed app is newer, the row shows "Requires an update" instead of running the script. Optional โ€” use only if your script depends on behavior later removed or changed | | `[ScriptIcon('๐Ÿš€')]` | Emoji or glyph shown on the row | diff --git a/README.md b/README.md index 1a6dbe0..fa9294a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ When the extension is activated, `PaletteShellExtensionPage` does the following: 1. Resolves the scripts folder: the one saved in [Settings](#-settings), or โ€” for an install that predates that setting โ€” the existing `Documents\PaletteShellScripts` if it's already there. If neither exists yet, the page shows a **"Choose scripts folder"** prompt (suggesting `Documents\PaletteShellScripts`) instead of a script list, and the rest of this flow runs once you submit it. 2. Creates that directory if it doesn't exist. 3. Copies the embedded sample scripts (only files that aren't already there, so your edits are never overwritten). -4. Copies the `PaletteScriptAttributes.psm1` module and `TextCopy.dll` next to the scripts so they're available at runtime. +4. Copies the `PaletteScriptAttributes.psm1` module, `TextCopy.dll`, and the `AGENTS.md` authoring spec next to the scripts so they're available at runtime. 5. Enumerates every `*.ps1` file in the folder (top level only) and builds the command list. The list always begins with four built-in actions: @@ -64,7 +64,7 @@ If a script fails to parse (e.g. a malformed `param()` block), it isn't dropped A script that declares `[RequiresPaletteShellMinimum(...)]` / `[RequiresPaletteShellMaximum(...)]` outside the installed app's version range also stays visible, but shows a **โš  Requires an update** row (naming the version it needs) instead of running, so you know to update PaletteShell rather than seeing a broken script. -Scripts are also **version-stamped on load**: any script that declares no `[ScriptVersion(...)]` gets `[ScriptVersion('1.0.0')]` backfilled into it (idempotent and best-effort โ€” files it can't safely rewrite are left untouched), so every managed script carries a version tools can compare. +A script that declares no `[ScriptVersion(...)]` is **treated as `1.0.0`** when loaded โ€” the file itself is never rewritten. Scripts scaffolded through the **"Create new script"** wizard get a `[ScriptVersion('1.0.0')]` written in up front, so every managed script carries a version tools can compare. > โ„น๏ธ New scripts and edits are picked up only when you run **"Reload scripts"** โ€” this is intentional, not a bug. Reloading shows a **"Reloaded N scripts"** toast so you know the rescan ran. @@ -193,7 +193,8 @@ The agent reads `AGENTS.md`, produces a compliant script in the folder, and you | `[ScriptTimeout(30000)]` | Timeout in milliseconds; also forces wait-and-capture. Omit it to use the [default timeout setting](#-settings) | | `[ScriptGroup('Category')]` | Group/category name for tooling such as the Script Manager catalog browser | | `[ScriptTags('foo,bar')]` | Comma-delimited free-form tags for tooling such as the Script Manager catalog browser | -| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) so tooling can detect when a newer copy is available. Scripts that omit it are stamped with `1.0.0` on load | +| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) so tooling can detect when a newer copy is available. A script that omits it is treated as `1.0.0` | +| `[RequiresModule('ImportExcel')]` | PowerShell module the script needs installed (repeat for more than one). Checked before the run โ€” a missing module fails with an `Install-Module -Name โ€ฆ -Scope CurrentUser` hint instead of the script's own cryptic error | | `[RequiresPaletteShellMinimum('1.2.0')]` | Minimum PaletteShell version required; older installs show a **"Requires an update"** row instead of running | | `[RequiresPaletteShellMaximum('2.0.0')]` | Maximum PaletteShell version supported; newer installs show a **"Requires an update"** row instead of running | | `[ScriptIcon('๐Ÿš€')]` | Icon emoji or glyph shown in the palette | @@ -327,6 +328,8 @@ Parameters in your `param()` block automatically become form fields: - `[ValidateSet('A','B','C')]` โ†’ dropdown - `[Parameter(Mandatory=$true)]` โ†’ required field +By default a form value reaches the script as a **literal string**. Mark a parameter `[AllowExpression()]` to have its value passed through as an evaluated PowerShell expression instead of being quoted. + ### Helper Functions When you `using module .\PaletteScriptAttributes.psm1`, these functions are available: @@ -386,16 +389,22 @@ dotnet build PaletteShellExtension/PaletteShellExtension.csproj |------|----------------| | `PaletteShellExtension.cs` | Extension entry point; provides the commands provider to Command Palette | | `PaletteShellExtensionCommandsProvider.cs` | Registers the top-level PaletteShell command | -| `Pages/PaletteShellExtensionPage.cs` | Main list page โ€” discovery, sample/module copying, item building | +| `Pages/PaletteShellExtensionPage.cs` | Main list page โ€” discovery, sample/module/`AGENTS.md` copying, item building | +| `Classes/SampleScriptInstaller.cs` | Tracks the hash of each written sample plus the last synced app version (in `sample-scripts.json`) so an unchanged install can skip the sample sync | | `PowerShellScriptParser.cs` | Parses script metadata and parameters with a lightweight text parser | | `Classes/ScriptManifest.cs`, `ScriptParameter.cs` | Parsed metadata models | -| `Classes/ScriptRunner.cs` | Builds the process and runs scripts (fire-and-forget or wait-and-capture) | +| `Classes/ScriptRunner.cs` | Builds the process and runs scripts (fire-and-forget or wait-and-capture); preflights `[RequiresModule]` dependencies | | `Classes/ScriptOutputHandler.cs` | Maps captured output to a result per the script's output mode | +| `Classes/ScriptFailureReport.cs` | Models a failed run for `ScriptFailurePresenter` to surface | +| `Classes/ScriptElevation.cs` | Detects elevation requirement and gates it against incompatible output modes | | `Classes/ScriptStatus.cs` | Shows the "Runningโ€ฆ" spinner in the status bar while a script runs | | `Classes/PinnedScripts.cs` | Tracks pinned scripts (persisted to `pinned.txt`) so they sort to the top | -| `Classes/ScriptVersionStamper.cs` | Backfills `[ScriptVersion('1.0.0')]` into scripts that omit it during the folder scan (idempotent, best-effort) | +| `Classes/InstalledCommunityScripts.cs` | Records which local scripts came from the community catalog so update checks can compare shas | +| `Classes/AppVersion.cs` | Resolves the running PaletteShell version and checks a script's version range | | `Classes/RecycleBin.cs` | Sends a deleted script to the Windows Recycle Bin via `SHFileOperation` | | `Classes/EditorLauncher.cs` | Opens a script in the preferred editor setting, `$VISUAL`/`$EDITOR`, or Notepad | +| `Classes/PowerShellQuoting.cs` | Quotes/escapes form values passed to the script (unless `[AllowExpression()]`) | +| `Classes/Log.cs` | Lightweight diagnostic logging | | `Classes/PaletteShellSettingsManager.cs` | Backs the Settings page (scripts folder, default host, default timeout, preferred editor) and persists it to `settings.json` | | `Pages/ScriptsFolderSetupPage.cs`, `Forms/ScriptsFolderSetupForm.cs` | First-run (and re-run) prompt that collects the scripts folder | | `Commands/RunScriptCommand.cs` | Runs a parameterless script and handles output/clipboard/toast/confirmation | @@ -404,6 +413,9 @@ dotnet build PaletteShellExtension/PaletteShellExtension.csproj | `Commands/DeleteScriptCommand.cs` | Deletes a script to the Recycle Bin after a confirmation dialog | | `Commands/RevealInExplorerCommand.cs` | Opens File Explorer with the script file selected | | `Commands/OpenInEditorCommand.cs`, `OpenFolderCommand.cs`, `OpenLinkCommand.cs`, `ReloadPageCommand.cs` | Built-in and per-item commands | +| `Commands/LaunchCommunityStoreCommand.cs` | "Browse community scripts" โ€” opens the Script Manager, falling back to the GitHub repo | +| `Commands/IncompatibleScriptCommand.cs`, `ElevationIncompatibleCommand.cs` | Shown in place of running when a script's version range or elevation/output-mode combo can't run | +| `Commands/ScriptFailurePresenter.cs` | Surfaces a failed run (`ScriptFailureReport`) to the user | | `Pages/ScriptParameterFormPage.cs`, `Forms/ScriptParameterForm.cs` | Auto-generated input form for parameterized scripts | | `Pages/ScriptMarkdownPage.cs` | Runs a script and renders its output as Markdown | | `Pages/ScriptListPage.cs` | Runs a script and turns its stdout into a searchable, pickable list | From 67f8c66fdc22f19d2776e0252b83c471da3c477c Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:23:48 -0400 Subject: [PATCH 15/21] modularizing some things better --- .../ScriptParameterFormTests.cs | 12 +- .../ScriptVersionStamperTests.cs | 107 ------------------ .../Classes/ScriptArgumentBuilder.cs | 57 ++++++++++ .../Classes/ScriptCompatibilityValidator.cs | 42 +++++++ .../Classes/ScriptExecutionPlan.cs | 53 +++++++++ .../Classes/ScriptExecutionService.cs | 92 +++++++++++++++ .../Commands/RunScriptCommand.cs | 49 ++------ .../Forms/ScriptParameterForm.cs | 82 +++----------- .../Pages/PaletteShellExtensionPage.cs | 63 ++++------- PaletteShellExtension/Pages/ScriptListPage.cs | 53 ++------- .../Pages/ScriptMarkdownPage.cs | 34 ++---- .../Pages/ScriptParameterFormPage.cs | 14 +-- .../Pages/ScriptResultPage.cs | 32 ++---- 13 files changed, 324 insertions(+), 366 deletions(-) delete mode 100644 PaletteShellExtension.Tests/ScriptVersionStamperTests.cs create mode 100644 PaletteShellExtension/Classes/ScriptArgumentBuilder.cs create mode 100644 PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs create mode 100644 PaletteShellExtension/Classes/ScriptExecutionPlan.cs create mode 100644 PaletteShellExtension/Classes/ScriptExecutionService.cs diff --git a/PaletteShellExtension.Tests/ScriptParameterFormTests.cs b/PaletteShellExtension.Tests/ScriptParameterFormTests.cs index 0882e0d..ca2054d 100644 --- a/PaletteShellExtension.Tests/ScriptParameterFormTests.cs +++ b/PaletteShellExtension.Tests/ScriptParameterFormTests.cs @@ -17,6 +17,14 @@ public class ScriptParameterFormTests Parameters = [new ScriptParameter { Name = "Name", Type = "string", Required = true, Label = label }] }; + // A minimal execution plan for the early-exit paths, which never reach script execution. + private static ScriptExecutionPlan MinimalPlan() => new() + { + ScriptPath = ScriptPath, + Host = "pwsh", + Env = new(), + }; + // ----- GetMissingRequiredFields (pure logic, no script execution) ----------------------- [Fact] @@ -83,7 +91,7 @@ public void GetMissingRequiredFields_OptionalFieldEmpty_IsNotReported() [Fact] public void SubmitForm_MissingRequiredField_KeepsFormOpenWithToast() { - var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName()); + var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName(), MinimalPlan()); var result = form.SubmitForm("""{"Name":""}""", """{"verb":"run"}"""); @@ -97,7 +105,7 @@ public void SubmitForm_MissingRequiredField_KeepsFormOpenWithToast() [Fact] public void SubmitForm_Cancel_Dismisses() { - var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName()); + var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName(), MinimalPlan()); var result = form.SubmitForm("""{"Name":""}""", """{"verb":"cancel"}"""); diff --git a/PaletteShellExtension.Tests/ScriptVersionStamperTests.cs b/PaletteShellExtension.Tests/ScriptVersionStamperTests.cs deleted file mode 100644 index f770a16..0000000 --- a/PaletteShellExtension.Tests/ScriptVersionStamperTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.IO; -using PaletteShellExtension; -using PaletteShellExtension.Classes; -using Xunit; - -namespace PaletteShellExtension.Tests; - -public class ScriptVersionStamperTests -{ - [Fact] - public void AddsVersion_ImmediatelyBeforeParam_WhenMissing() - { - using var file = new TestScriptFile("[ScriptGroup('Tools')]\n[CmdletBinding()]\nparam()\n\nWrite-Host 'hi'\n"); - - var stamped = ScriptVersionStamper.TryStamp(file.Path); - - Assert.True(stamped); - var text = File.ReadAllText(file.Path); - Assert.Contains("[ScriptVersion('1.0.0')]", text); - // Inserted at the param block, after the pre-existing attributes. - Assert.Matches(@"\[CmdletBinding\(\)\]\s*\[ScriptVersion\('1\.0\.0'\)\]\s*param\(", text); - // The body is untouched. - Assert.Contains("Write-Host 'hi'", text); - } - - [Fact] - public void StampedScript_ParsesToVersion1_0_0() - { - using var file = new TestScriptFile("[ScriptGroup('Tools')]\nparam()\n"); - - ScriptVersionStamper.TryStamp(file.Path); - - var manifest = PowerShellScriptParser.TryParseManifest(file.Path); - Assert.Equal("1.0.0", manifest!.Version); - } - - [Fact] - public void Idempotent_LeavesAnAlreadyVersionedScriptUntouched() - { - const string original = "[ScriptVersion('2.3.4')]\nparam()\n"; - using var file = new TestScriptFile(original); - - var stamped = ScriptVersionStamper.TryStamp(file.Path); - - Assert.False(stamped); - Assert.Equal(original, File.ReadAllText(file.Path)); - } - - [Fact] - public void RunningTwice_StampsOnlyOnce() - { - using var file = new TestScriptFile("param()\n"); - - Assert.True(ScriptVersionStamper.TryStamp(file.Path)); - Assert.False(ScriptVersionStamper.TryStamp(file.Path)); - - var text = File.ReadAllText(file.Path); - Assert.Single(System.Text.RegularExpressions.Regex.Matches(text, @"\[ScriptVersion\(")); - } - - [Fact] - public void SkipsScript_WithNoParamBlock() - { - const string original = "[ScriptGroup('Tools')]\nWrite-Host 'no param here'\n"; - using var file = new TestScriptFile(original); - - var stamped = ScriptVersionStamper.TryStamp(file.Path); - - Assert.False(stamped); - Assert.Equal(original, File.ReadAllText(file.Path)); - } - - [Fact] - public void DoesNotMatch_ParamKeywordInsideAComment() - { - // "param(" only appears in a comment, never as a real block - nothing safe to attach to. - const string original = "# this script has no real param( block\nWrite-Host 'x'\n"; - using var file = new TestScriptFile(original); - - Assert.False(ScriptVersionStamper.TryStamp(file.Path)); - Assert.Equal(original, File.ReadAllText(file.Path)); - } - - [Fact] - public void PreservesCrlfNewlines() - { - using var file = new TestScriptFile("[ScriptGroup('Tools')]\r\nparam()\r\n"); - - ScriptVersionStamper.TryStamp(file.Path); - - var text = File.ReadAllText(file.Path); - Assert.Contains("[ScriptVersion('1.0.0')]\r\n", text); - Assert.DoesNotContain("[ScriptVersion('1.0.0')]\n\n", text); // no stray lone-LF introduced - } - - [Fact] - public void PreservesLfNewlines() - { - using var file = new TestScriptFile("[ScriptGroup('Tools')]\nparam()\n"); - - ScriptVersionStamper.TryStamp(file.Path); - - var text = File.ReadAllText(file.Path); - Assert.Contains("[ScriptVersion('1.0.0')]\n", text); - Assert.DoesNotContain("\r\n", text); // an LF file stays LF - } -} diff --git a/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs b/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs new file mode 100644 index 0000000..cc97e5b --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace PaletteShellExtension.Classes; + +/// +/// Builds the PowerShell argument line for a run, in one place, so every surface quotes values the +/// same way. Values are single-quoted literals (so $, ;, backticks and quotes reach +/// the script intact rather than being evaluated) unless the parameter is a bool or is marked +/// [AllowExpression]. +/// +internal static class ScriptArgumentBuilder +{ + /// Builds -Name value pairs from an Adaptive Card's submitted input object, + /// skipping empty optional parameters. + public static string BuildFromForm(IReadOnlyList parameters, JsonObject values) + { + var args = new List(); + foreach (var param in parameters) + { + var value = values[param.Name]?.ToString(); + + // Skip empty optional parameters so the script's own default applies. + if (string.IsNullOrWhiteSpace(value) && param.Required != true) + continue; + + args.Add($"-{param.Name}"); + args.Add(FormatArgValue(param, value ?? "")); + } + + return string.Join(" ", args); + } + + /// Builds the single -Query argument that passes a live List provider's search + /// text. Empty text yields an empty line so the script's own default applies. + public static string BuildQueryArg(string queryParam, string? query) + => string.IsNullOrEmpty(query) + ? "" + : $"-{queryParam} {PowerShellQuoting.SingleQuote(query)}"; + + /// + /// Formats a single value as a PowerShell command-line argument. Booleans become + /// $true/$false; parameters marked [AllowExpression] are injected verbatim + /// so PowerShell evaluates them; everything else is a single-quoted literal. + /// + public static string FormatArgValue(ScriptParameter param, string value) + { + if (param.Type == "bool") + return value.Equals("true", StringComparison.OrdinalIgnoreCase) ? "$true" : "$false"; + + if (param.AllowExpression) + return value; + + return PowerShellQuoting.SingleQuote(value); + } +} diff --git a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs new file mode 100644 index 0000000..f0ef359 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs @@ -0,0 +1,42 @@ +namespace PaletteShellExtension.Classes; + +/// The outcome kind of a pre-run compatibility check. +internal enum ScriptCompatibilityKind +{ + /// The script can run on this route. + Ok, + + /// The running app version is outside the script's declared min/max range. + RequiresUpdate, + + /// The script asks to run elevated but also declares a capturing output mode โ€” an + /// impossible combination (elevation can't redirect stdout). + ElevationIncompatible, +} + +/// +/// The single place a script's manifest is checked for runnability before a route is chosen โ€” +/// app-version range and the elevation/output-capture conflict. Collapses the two gates that used +/// to sit inline in the routing loop into one call so the checks can't drift. +/// +internal readonly record struct ScriptCompatibility( + ScriptCompatibilityKind Kind, + string? RequiredVersion = null, + bool TooNew = false) +{ + private static readonly ScriptCompatibility Compatible = new(ScriptCompatibilityKind.Ok); + + public static ScriptCompatibility Validate(ScriptManifest? manifest) + { + if (manifest is null) + return Compatible; + + if (!AppVersion.IsCompatible(manifest.MinVersion, manifest.MaxVersion, out var required, out var tooNew)) + return new ScriptCompatibility(ScriptCompatibilityKind.RequiresUpdate, required!.ToString(), tooNew); + + if (ScriptElevation.IsElevatedOutputIncompatible(manifest)) + return new ScriptCompatibility(ScriptCompatibilityKind.ElevationIncompatible); + + return Compatible; + } +} diff --git a/PaletteShellExtension/Classes/ScriptExecutionPlan.cs b/PaletteShellExtension/Classes/ScriptExecutionPlan.cs new file mode 100644 index 0000000..cdbe496 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptExecutionPlan.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace PaletteShellExtension.Classes; + +/// +/// Immutable snapshot of every decision needed to run a script โ€” host, working directory, +/// environment, timeout, elevation, output mode. Built once by +/// from a manifest so no execution route +/// re-derives these (which is what let elevation drift between routes). Callers supply only the +/// per-invocation argument line. +/// +internal sealed class ScriptExecutionPlan +{ + public required string ScriptPath { get; init; } + + /// Interpreter host token ("auto"/"pwsh"/"powershell"), resolved + /// from the manifest with the configured default folded in. turns + /// this into the actual executable. + public required string Host { get; init; } + + public string? Cwd { get; init; } + + /// Environment overrides with path tokens ({ScriptDir}/{Home}/{Temp}) already expanded. + public required Dictionary Env { get; init; } + + public IReadOnlyList RequiredModules { get; init; } = []; + + public string OutputMode { get; init; } = "None"; + + public string? FileExtension { get; init; } + + /// Single canonical elevation decision (from ), + /// so no route hardcodes it. + public bool RequiresAdmin { get; init; } + + /// The script's declared [ScriptTimeout(...)], or null when it declared none. + /// Null lets the no-output fire-and-forget shortcut apply; + /// folds in the configured default for the waiting paths. + public int? DeclaredTimeoutMs { get; init; } + + /// The declared timeout, or the configured default when the script declared none. + /// Every waiting run is bounded by this. + public int EffectiveTimeoutMs { get; init; } + + /// Elevated runs use runas + UseShellExecute, which can't redirect + /// stdout, so only an unelevated run captures output. + public bool CaptureOutput => !RequiresAdmin; + + /// True when the declared output mode needs the script's stdout (anything but None). + public bool SurfacesOutput + => !string.Equals(OutputMode, "None", StringComparison.OrdinalIgnoreCase); +} diff --git a/PaletteShellExtension/Classes/ScriptExecutionService.cs b/PaletteShellExtension/Classes/ScriptExecutionService.cs new file mode 100644 index 0000000..f2782a5 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptExecutionService.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PaletteShellExtension.Classes; + +/// +/// The single entry point for running a script. Resolves a from +/// a manifest (host, cwd, env, timeout, elevation) and runs it through , +/// so every surface โ€” the run command, the parameter form, and the List/Markdown/Result pages โ€” +/// shares one set of execution decisions instead of each re-deriving them. +/// +internal static class ScriptExecutionService +{ + /// Resolves all execution decisions for once. This is the + /// only place host/cwd/env/timeout/elevation are decided. + public static ScriptExecutionPlan CreatePlan(ScriptManifest manifest, string scriptPath) + { + // Expand path tokens ({ScriptDir}/{Home}/{Temp}) in env values here so every route gets + // the same expanded environment โ€” previously only the no-parameter command did this. + var expandedEnv = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in manifest.Env) + { + expandedEnv[kv.Key] = PowerShellScriptParser.ExpandPathTokens(kv.Value, scriptPath) ?? ""; + } + + var declaredTimeout = manifest.TimeoutMs is > 0 ? manifest.TimeoutMs : null; + + return new ScriptExecutionPlan + { + ScriptPath = scriptPath, + Host = manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, + Cwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, scriptPath), + Env = expandedEnv, + RequiredModules = manifest.RequiredModules, + OutputMode = manifest.Output, + FileExtension = manifest.FileExtension, + RequiresAdmin = ScriptElevation.RequiresElevation(manifest), + DeclaredTimeoutMs = declaredTimeout, + EffectiveTimeoutMs = declaredTimeout ?? PaletteShellSettingsManager.Instance.DefaultTimeoutMs, + }; + } + + /// Runs the plan and awaits the result โ€” for callers already off the UI thread + /// (the List/Markdown/Result pages). Bounded by . + public static Task RunAsync( + ScriptExecutionPlan plan, + string args = "", + bool reportProgress = true, + CancellationToken cancellationToken = default) + => ScriptRunner.RunScriptAndWaitAsync( + scriptPath: plan.ScriptPath, + args: args, + host: plan.Host, + cwd: plan.Cwd, + env: plan.Env, + requiresAdmin: plan.RequiresAdmin, + timeoutMs: plan.EffectiveTimeoutMs, + reportProgress: reportProgress, + requiredModules: plan.RequiredModules, + cancellationToken: cancellationToken); + + /// Runs the plan and blocks for the result โ€” for the synchronous Invoke() and + /// form-submit entry points. + public static ScriptRunner.ScriptResult? RunAndWait( + ScriptExecutionPlan plan, + string args = "", + bool reportProgress = true) + => ScriptRunner.RunScriptAndWait( + scriptPath: plan.ScriptPath, + args: args, + host: plan.Host, + cwd: plan.Cwd, + env: plan.Env, + requiresAdmin: plan.RequiresAdmin, + timeoutMs: plan.EffectiveTimeoutMs, + reportProgress: reportProgress, + requiredModules: plan.RequiredModules); + + /// Launches the plan without waiting โ€” used when there's nothing to surface (output + /// None with no declared timeout) or when elevation makes output capture impossible. + public static bool RunFireAndForget(ScriptExecutionPlan plan, string args = "") + => ScriptRunner.RunScript( + scriptPath: plan.ScriptPath, + args: args, + host: plan.Host, + cwd: plan.Cwd, + env: plan.Env, + requiresAdmin: plan.RequiresAdmin, + requiredModules: plan.RequiredModules); +} diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index 62ae760..7cc8c41 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -2,8 +2,6 @@ using PaletteShellExtension.Classes; using PaletteShellExtension.Forms; using PaletteShellExtension.Pages; -using System; -using System.Collections.Generic; using System.IO; namespace PaletteShellExtension.Commands; @@ -37,59 +35,29 @@ public override CommandResult Invoke() private CommandResult RunNow() { - var wantsAdmin = _manifest.RequiresAdmin == true; + var plan = ScriptExecutionService.CreatePlan(_manifest, path); - // CWD - var cwd = PowerShellScriptParser.ResolveCwd(_manifest.Cwd, path); - - // Env - var expandedEnv = new Dictionary(); - foreach (var kv in _manifest.Env) - { - expandedEnv[kv.Key] = ExpandPathTokens(kv.Value, path) ?? ""; - } - - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : (int?)null; - - var host = _manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; - - // "None" never surfaces output, so when there's also no timeout we can run + // "None" never surfaces output, so when there's also no declared timeout we can run // fire-and-forget without waiting for/capturing stdout. Any other mode // (Toast/Clipboard) needs the output, so we must wait even without a timeout. - var surfacesOutput = !string.Equals(_manifest.Output, "None", StringComparison.OrdinalIgnoreCase); - if (timeout is null && !surfacesOutput) + if (plan.DeclaredTimeoutMs is null && !plan.SurfacesOutput) { - ScriptRunner.RunScript( - scriptPath: path, - args: "", - host: host, - cwd: cwd, - env: expandedEnv, - requiresAdmin: wantsAdmin, - requiredModules: _manifest.RequiredModules); + ScriptExecutionService.RunFireAndForget(plan); return CommandResult.ShowToast("Script completed"); } // Wait so the declared output mode can be honored, falling back to a default // timeout when the script didn't specify one. - var result = ScriptRunner.RunScriptAndWait( - scriptPath: path, - args: "", - host: host, - cwd: cwd, - env: expandedEnv, - requiresAdmin: wantsAdmin, - timeoutMs: timeout ?? PaletteShellSettingsManager.Instance.DefaultTimeoutMs, - requiredModules: _manifest.RequiredModules); + var result = ScriptExecutionService.RunAndWait(plan); // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose // "View details" opens the full failure report โ€” a toast is too small and too // short-lived to explain what went wrong. if (result == null || result.TimedOut || result.ExitCode != 0) - return ScriptFailurePresenter.ToCommandResult(path, host, "", result); + return ScriptFailurePresenter.ToCommandResult(path, plan.Host, "", result); // Elevated scripts can't have their output captured, so suppress output handling. - var output = !wantsAdmin ? result.StandardOutput : null; + var output = plan.CaptureOutput ? result.StandardOutput : null; return ScriptOutputHandler.ToResult( _manifest.Output, @@ -97,7 +65,4 @@ private CommandResult RunNow() _manifest.FileExtension, Path.GetFileNameWithoutExtension(path)); } - - private static string? ExpandPathTokens(string? path, string scriptPath) - => PowerShellScriptParser.ExpandPathTokens(path, scriptPath); } diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index 4070370..6337f4e 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -13,9 +13,7 @@ internal sealed class ScriptParameterForm : FormContent { private readonly string _scriptPath; private readonly ScriptManifest _manifest; - private readonly string _host; - private readonly string? _cwd; - private readonly Dictionary _env; + private readonly ScriptExecutionPlan _plan; private readonly Action? _onMarkdown; private readonly Action? _onRunStarted; private readonly Action? _onRunFinished; @@ -23,9 +21,7 @@ internal sealed class ScriptParameterForm : FormContent public ScriptParameterForm( string scriptPath, ScriptManifest manifest, - string? host = null, - string? cwd = null, - Dictionary? env = null, + ScriptExecutionPlan plan, Action? onMarkdown = null, Action? onRunStarted = null, Action? onRunFinished = null) @@ -33,9 +29,7 @@ public ScriptParameterForm( _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? PaletteShellSettingsManager.Instance.DefaultHost; - _cwd = cwd; - _env = env ?? new(StringComparer.OrdinalIgnoreCase); + _plan = plan; _onMarkdown = onMarkdown; _onRunStarted = onRunStarted; _onRunFinished = onRunFinished; @@ -71,21 +65,8 @@ public override CommandResult SubmitForm(string inputs, string data) }); } - // Build argument list from form values - var args = new List(); - foreach (var param in _manifest.Parameters) - { - var value = obj[param.Name]?.ToString(); - - // Skip empty optional parameters - if (string.IsNullOrWhiteSpace(value) && param.Required != true) - continue; - - args.Add($"-{param.Name}"); - args.Add(FormatArgValue(param, value ?? "")); - } - - var argsLine = string.Join(" ", args); + // Build argument line from form values (quoting centralized in ScriptArgumentBuilder). + var argsLine = ScriptArgumentBuilder.BuildFromForm(_manifest.Parameters, obj); // Markdown output renders in place on this page, so run it asynchronously and show a // "Runningโ€ฆ" spinner while it works โ€” a slow script (e.g. an event-log query) no @@ -132,15 +113,9 @@ private CommandResult StartMarkdownRun(string argsLine) string body; try { - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; - var result = ScriptRunner.RunScriptAndWait( - scriptPath: _scriptPath, - args: argsLine, - host: _host, - cwd: _cwd, - env: _env, - requiresAdmin: false, - timeoutMs: timeout); + // Markdown mode is never elevated (the compatibility gate blocks elevated + + // capturing output), so the plan's RequiresAdmin is false here. + var result = ScriptExecutionService.RunAndWait(_plan, argsLine); body = FormatInPageResult(result); } @@ -195,36 +170,20 @@ private CommandResult Execute(string argsLine) // Elevated scripts can't capture output, so the routing gate only lets an elevated // script reach here when its output is None. Launch it elevated fire-and-forget // (runas honors ArgumentList/args) โ€” there's nothing to wait for or surface. - if (ScriptElevation.RequiresElevation(_manifest)) + if (_plan.RequiresAdmin) { - ScriptRunner.RunScript( - scriptPath: _scriptPath, - args: argsLine, - host: _host, - cwd: _cwd, - env: _env, - requiresAdmin: true, - requiredModules: _manifest.RequiredModules); + ScriptExecutionService.RunFireAndForget(_plan, argsLine); return CommandResult.ShowToast("Script completed"); } // Run script and wait for completion - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; - var result = ScriptRunner.RunScriptAndWait( - scriptPath: _scriptPath, - args: argsLine, - host: _host, - cwd: _cwd, - env: _env, - requiresAdmin: false, - timeoutMs: timeout, - requiredModules: _manifest.RequiredModules); + var result = ScriptExecutionService.RunAndWait(_plan, argsLine); // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose // "View details" opens the full failure report โ€” a toast is too small and too // short-lived to explain what went wrong. if (result == null || result.TimedOut || result.ExitCode != 0) - return ScriptFailurePresenter.ToCommandResult(_scriptPath, _host, argsLine, result); + return ScriptFailurePresenter.ToCommandResult(_scriptPath, _plan.Host, argsLine, result); // Markdown output - render the result in place instead of a toast. var wantsMarkdown = string.Equals(_manifest.Output, "Markdown", StringComparison.OrdinalIgnoreCase); @@ -390,6 +349,7 @@ private string BuildTemplateJson() private static string BuildDataJson() => new JsonObject().ToJsonString(); + private static string? ParseVerb(string? data) { if (string.IsNullOrWhiteSpace(data)) @@ -404,20 +364,4 @@ private string BuildTemplateJson() } } - /// - /// Formats a form value as a PowerShell command-line argument. Booleans become - /// $true/$false; parameters marked [AllowExpression] are injected - /// verbatim so PowerShell evaluates them; everything else is a single-quoted literal so - /// $, ;, backticks and quotes reach the script intact rather than being evaluated. - /// - private static string FormatArgValue(ScriptParameter param, string value) - { - if (param.Type == "bool") - return value.Equals("true", StringComparison.OrdinalIgnoreCase) ? "$true" : "$false"; - - if (param.AllowExpression) - return value; - - return PowerShellQuoting.SingleQuote(value); - } } diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index 5920560..e3c8524 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -387,13 +387,16 @@ public override IListItem[] GetItems() var title = manifest?.Title ?? Path.GetFileNameWithoutExtension(path); var subtitle = manifest?.Description ?? path; - if (manifest is not null && !AppVersion.IsCompatible(manifest.MinVersion, manifest.MaxVersion, out var requiredVersion, out var tooNew)) + // One gate covers app-version range and the elevation/output-capture conflict for + // every route below: a blocked script gets a warning row instead of a runnable one. + var compat = ScriptCompatibility.Validate(manifest); + if (compat.Kind == ScriptCompatibilityKind.RequiresUpdate) { var incompatiblePinned = pins.IsPinned(path); - var incompatibleSubtitle = tooNew - ? $"โš  Requires PaletteShell v{requiredVersion} or earlier โ€” you have v{AppVersion.Current}" - : $"โš  Requires PaletteShell v{requiredVersion} or later โ€” you have v{AppVersion.Current}"; - scriptResults[i] = (incompatiblePinned, title, new ListItem(new IncompatibleScriptCommand(requiredVersion!.ToString(), AppVersion.Current.ToString(), tooNew)) + var incompatibleSubtitle = compat.TooNew + ? $"โš  Requires PaletteShell v{compat.RequiredVersion} or earlier โ€” you have v{AppVersion.Current}" + : $"โš  Requires PaletteShell v{compat.RequiredVersion} or later โ€” you have v{AppVersion.Current}"; + scriptResults[i] = (incompatiblePinned, title, new ListItem(new IncompatibleScriptCommand(compat.RequiredVersion!, AppVersion.Current.ToString(), compat.TooNew)) { Title = title, Subtitle = incompatibleSubtitle, @@ -403,10 +406,7 @@ public override IListItem[] GetItems() return; } - // Elevation can't capture output, so an elevated script declared with any - // capturing output mode is impossible. Block it with a warning row (rather than - // let a route run it unelevated) โ€” this one gate covers every route below. - if (manifest is not null && ScriptElevation.IsElevatedOutputIncompatible(manifest)) + if (compat.Kind == ScriptCompatibilityKind.ElevationIncompatible) { var elevationPinned = pins.IsPinned(path); scriptResults[i] = (elevationPinned, title, new ListItem(new ElevationIncompatibleCommand()) @@ -423,6 +423,12 @@ public override IListItem[] GetItems() var wantsList = string.Equals(manifest?.Output, "List", StringComparison.OrdinalIgnoreCase); var wantsResult = string.Equals(manifest?.Output, "Result", StringComparison.OrdinalIgnoreCase); + // Resolve host/cwd/env/timeout/elevation once, here, so every route below shares + // the same execution decisions instead of each re-deriving them. + var plan = manifest is not null + ? ScriptExecutionService.CreatePlan(manifest, path) + : null; + ICommand command; if (wantsList && manifest is not null) { @@ -430,56 +436,25 @@ 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.ResolveCwd(manifest.Cwd, path); - - command = new ScriptListPage( - scriptPath: path, - manifest: manifest, - host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, - cwd: resolvedCwd, - env: manifest.Env); + command = new ScriptListPage(path, manifest, plan!); } else if (manifest?.Parameters is { Count: > 0 }) { // Script has parameters - navigate to parameter form page - var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); - - var formPage = new ScriptParameterFormPage( - scriptPath: path, - manifest: manifest, - host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, - cwd: resolvedCwd, - env: manifest.Env - ); - - command = formPage; + command = new ScriptParameterFormPage(path, manifest, plan!); } else if (wantsMarkdown && manifest is not null) { // No parameters, Markdown output - navigate to a page that runs // the script and renders its stdout as Markdown. - var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); - - command = new ScriptMarkdownPage( - scriptPath: path, - manifest: manifest, - host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, - cwd: resolvedCwd, - env: manifest.Env); + command = new ScriptMarkdownPage(path, manifest, plan!); } else if (wantsResult && manifest is not null) { // 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.ResolveCwd(manifest.Cwd, path); - - command = new ScriptResultPage( - scriptPath: path, - manifest: manifest, - host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, - cwd: resolvedCwd, - env: manifest.Env); + command = new ScriptResultPage(path, manifest, plan!); } else { diff --git a/PaletteShellExtension/Pages/ScriptListPage.cs b/PaletteShellExtension/Pages/ScriptListPage.cs index fee67cc..d2384b2 100644 --- a/PaletteShellExtension/Pages/ScriptListPage.cs +++ b/PaletteShellExtension/Pages/ScriptListPage.cs @@ -37,9 +37,7 @@ internal sealed partial class ScriptListPage : DynamicListPage { private readonly string _scriptPath; private readonly ScriptManifest _manifest; - private readonly string _host; - private readonly string? _cwd; - private readonly Dictionary _env; + private readonly ScriptExecutionPlan _plan; // When set, the page feeds the palette's search text to the script as this parameter // and re-runs on change; when null the script runs once and search filters locally. @@ -60,15 +58,11 @@ internal sealed partial class ScriptListPage : DynamicListPage public ScriptListPage( string scriptPath, ScriptManifest manifest, - string? host = null, - string? cwd = null, - Dictionary? env = null) + ScriptExecutionPlan plan) { _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; - _cwd = cwd; - _env = env ?? new(StringComparer.OrdinalIgnoreCase); + _plan = plan; _queryParam = manifest.Parameters.FirstOrDefault()?.Name; Title = manifest.Title ?? Path.GetFileNameWithoutExtension(scriptPath); @@ -148,24 +142,14 @@ private async Task ExecuteAsync(string? query, CancellationToken cancellationTok { try { - var args = BuildArgs(query); - - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; - - // Elevated scripts can't have their output captured, so List mode always - // runs unelevated โ€” there'd be nothing to list otherwise. Awaited rather than - // blocked on so a slow provider doesn't pin a threadpool thread per keystroke. - // The token deliberately isn't passed down: a superseded run's process finishes - // (or times out) on its own, matching the old version-counter behavior. - var result = await ScriptRunner.RunScriptAndWaitAsync( - scriptPath: _scriptPath, - args: args, - host: _host, - cwd: _cwd, - env: _env, - requiresAdmin: false, - timeoutMs: timeout, - requiredModules: _manifest.RequiredModules); + var args = _queryParam is null ? "" : ScriptArgumentBuilder.BuildQueryArg(_queryParam, query); + + // Elevated scripts can't have their output captured, so an elevated script never + // reaches List mode (the compatibility gate blocks it) โ€” the plan's RequiresAdmin is + // false here. Awaited rather than blocked on so a slow provider doesn't pin a + // threadpool thread per keystroke. The token deliberately isn't passed down: a + // superseded run's process finishes (or times out) on its own. + var result = await ScriptExecutionService.RunAsync(_plan, args); if (cancellationToken.IsCancellationRequested) { @@ -191,19 +175,6 @@ private async Task ExecuteAsync(string? query, CancellationToken cancellationTok } } - /// Builds the command-line argument that passes the search text to the script's - /// query parameter. Empty text is omitted so the script's own default applies. - private string BuildArgs(string? query) - { - if (_queryParam is null || string.IsNullOrEmpty(query)) - { - return ""; - } - - // Single-quote the literal so paths and spaces reach the script intact. - return $"-{_queryParam} {PowerShellQuoting.SingleQuote(query)}"; - } - private static IListItem[] Filter(IListItem[] items, string? search) { if (string.IsNullOrWhiteSpace(search)) @@ -221,7 +192,7 @@ private IListItem[] BuildItems(ScriptRunner.ScriptResult? result, string args) // Failures get an actionable row (Enter opens the full failure report) instead of // an inert message, so the user can see the whole error rather than a summary. if (result is null || result.TimedOut || result.ExitCode != 0) - return [ScriptFailurePresenter.ToListItem(_scriptPath, _host, args, result)]; + return [ScriptFailurePresenter.ToListItem(_scriptPath, _plan.Host, args, result)]; var output = result.StandardOutput; if (string.IsNullOrWhiteSpace(output)) diff --git a/PaletteShellExtension/Pages/ScriptMarkdownPage.cs b/PaletteShellExtension/Pages/ScriptMarkdownPage.cs index 1c97128..4d36cd5 100644 --- a/PaletteShellExtension/Pages/ScriptMarkdownPage.cs +++ b/PaletteShellExtension/Pages/ScriptMarkdownPage.cs @@ -12,11 +12,7 @@ namespace PaletteShellExtension.Pages; // Used when a script declares ScriptOutput("Markdown"). internal sealed partial class ScriptMarkdownPage : ContentPage { - private readonly string _scriptPath; - private readonly ScriptManifest _manifest; - private readonly string _host; - private readonly string? _cwd; - private readonly Dictionary _env; + private readonly ScriptExecutionPlan _plan; private readonly string _args; private readonly MarkdownContent _content = new(); @@ -27,16 +23,10 @@ internal sealed partial class ScriptMarkdownPage : ContentPage public ScriptMarkdownPage( string scriptPath, ScriptManifest manifest, - string? host = null, - string? cwd = null, - Dictionary? env = null, + ScriptExecutionPlan plan, string args = "") { - _scriptPath = scriptPath; - _manifest = manifest; - _host = host ?? manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; - _cwd = cwd; - _env = env ?? new(StringComparer.OrdinalIgnoreCase); + _plan = plan; _args = args; Title = manifest.Title ?? Path.GetFileNameWithoutExtension(scriptPath); @@ -68,20 +58,10 @@ private async Task RunAndRender() { try { - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; - - // Elevated scripts can't have their output captured, so Markdown mode - // always runs unelevated to be able to render the result. Awaited rather than - // blocked on so the script's run time doesn't pin a threadpool thread. - var result = await ScriptRunner.RunScriptAndWaitAsync( - scriptPath: _scriptPath, - args: _args, - host: _host, - cwd: _cwd, - env: _env, - requiresAdmin: false, - timeoutMs: timeout, - requiredModules: _manifest.RequiredModules); + // Elevated scripts can't have their output captured, so an elevated script never + // reaches Markdown mode (the compatibility gate blocks it) โ€” the plan's RequiresAdmin + // is false here. Awaited rather than blocked on so the run doesn't pin a threadpool thread. + var result = await ScriptExecutionService.RunAsync(_plan, _args); _content.Body = FormatResult(result); } diff --git a/PaletteShellExtension/Pages/ScriptParameterFormPage.cs b/PaletteShellExtension/Pages/ScriptParameterFormPage.cs index a266447..095d1d2 100644 --- a/PaletteShellExtension/Pages/ScriptParameterFormPage.cs +++ b/PaletteShellExtension/Pages/ScriptParameterFormPage.cs @@ -12,9 +12,7 @@ internal sealed partial class ScriptParameterFormPage : ContentPage // Kept so the input form can be rebuilt fresh (see GetContent). private readonly string _scriptPath; private readonly ScriptManifest _manifest; - private readonly string? _host; - private readonly string? _cwd; - private readonly Dictionary? _env; + private readonly ScriptExecutionPlan _plan; private ScriptParameterForm _form; private readonly MarkdownContent _markdown = new(); @@ -28,15 +26,11 @@ internal sealed partial class ScriptParameterFormPage : ContentPage public ScriptParameterFormPage( string scriptPath, ScriptManifest manifest, - string? host = null, - string? cwd = null, - Dictionary? env = null) + ScriptExecutionPlan plan) { _scriptPath = scriptPath; _manifest = manifest; - _host = host; - _cwd = cwd; - _env = env; + _plan = plan; _form = CreateForm(); _content = [_form]; @@ -48,7 +42,7 @@ public ScriptParameterFormPage( } private ScriptParameterForm CreateForm() - => new(_scriptPath, _manifest, _host, _cwd, _env, ShowMarkdown, BeginRun, EndRun); + => new(_scriptPath, _manifest, _plan, ShowMarkdown, BeginRun, EndRun); // Called by the form the moment a run starts, so the user gets immediate feedback instead // of a frozen form: swap to a "Runningโ€ฆ" panel and turn on the page's loading spinner while diff --git a/PaletteShellExtension/Pages/ScriptResultPage.cs b/PaletteShellExtension/Pages/ScriptResultPage.cs index d808266..0f56d89 100644 --- a/PaletteShellExtension/Pages/ScriptResultPage.cs +++ b/PaletteShellExtension/Pages/ScriptResultPage.cs @@ -21,9 +21,7 @@ internal sealed partial class ScriptResultPage : ListPage { private readonly string _scriptPath; private readonly ScriptManifest _manifest; - private readonly string _host; - private readonly string? _cwd; - private readonly Dictionary _env; + private readonly ScriptExecutionPlan _plan; private IListItem[] _items = []; private bool _started; @@ -35,15 +33,11 @@ internal sealed partial class ScriptResultPage : ListPage public ScriptResultPage( string scriptPath, ScriptManifest manifest, - string? host = null, - string? cwd = null, - Dictionary? env = null) + ScriptExecutionPlan plan) { _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; - _cwd = cwd; - _env = env ?? new(StringComparer.OrdinalIgnoreCase); + _plan = plan; Title = manifest.Title ?? Path.GetFileNameWithoutExtension(scriptPath); Name = "Run"; @@ -82,20 +76,10 @@ private async Task Execute() { try { - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; - - // Elevated scripts can't have their output captured, so Result mode always runs - // unelevated โ€” there'd be no value to show otherwise. Awaited rather than - // blocked on so the script's run time doesn't pin a threadpool thread. - var result = await ScriptRunner.RunScriptAndWaitAsync( - scriptPath: _scriptPath, - args: "", - host: _host, - cwd: _cwd, - env: _env, - requiresAdmin: false, - timeoutMs: timeout, - requiredModules: _manifest.RequiredModules); + // Elevated scripts can't have their output captured, so an elevated script never + // reaches Result mode (the compatibility gate blocks it) โ€” the plan's RequiresAdmin is + // false here. Awaited rather than blocked on so the run doesn't pin a threadpool thread. + var result = await ScriptExecutionService.RunAsync(_plan); _items = BuildItems(result); } @@ -116,7 +100,7 @@ private IListItem[] BuildItems(ScriptRunner.ScriptResult? result) // Failures get an actionable row (Enter opens the full failure report) instead of // an inert message, so the user can see the whole error rather than a summary. if (result is null || result.TimedOut || result.ExitCode != 0) - return [ScriptFailurePresenter.ToListItem(_scriptPath, _host, "", result)]; + return [ScriptFailurePresenter.ToListItem(_scriptPath, _plan.Host, "", result)]; var value = result.StandardOutput?.Trim(); if (string.IsNullOrEmpty(value)) From c934f125514aabd6e289a8ff9f524e29a25434de Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:26:34 -0400 Subject: [PATCH 16/21] fix bug with switches --- PaletteShellExtension/Classes/ScriptArgumentBuilder.cs | 10 ++++++++++ PaletteShellExtension/Forms/ScriptParameterForm.cs | 1 + PaletteShellExtension/PowerShellScriptParser.cs | 6 +++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs b/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs index cc97e5b..e72f9bb 100644 --- a/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs +++ b/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs @@ -21,6 +21,16 @@ public static string BuildFromForm(IReadOnlyList parameters, Js { var value = values[param.Name]?.ToString(); + // A [switch] is supplied by presence: emit bare -Name when on, omit entirely when + // off. Emitting -Name $false (as [bool] does) would mis-bind, and a false toggle + // would wrongly register in $PSBoundParameters. + if (param.Type == "switch") + { + if (value != null && value.Equals("true", StringComparison.OrdinalIgnoreCase)) + args.Add($"-{param.Name}"); + continue; + } + // Skip empty optional parameters so the script's own default applies. if (string.IsNullOrWhiteSpace(value) && param.Required != true) continue; diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index 6337f4e..d2ba262 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -281,6 +281,7 @@ private string BuildTemplateJson() switch (param.Type) { case "bool": + case "switch": return new JsonObject { ["type"] = "Input.Toggle", diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index a7bb4cb..5ab13c1 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -495,7 +495,10 @@ private static string MapType(string psType, bool hasValidateSet) return psType.ToLowerInvariant() switch { - "switch" or "bool" or "boolean" => "bool", + // A [switch] is supplied by presence, not by value, so it needs distinct + // argument-line handling from [bool] (see ScriptArgumentBuilder). + "switch" => "switch", + "bool" or "boolean" => "bool", "int" or "int32" or "int64" or "long" => "int", "double" or "float" or "single" or "decimal" => "number", _ => "string" @@ -517,6 +520,7 @@ private static string MapType(string psType, bool hasValidateSet) switch (uiType) { case "bool": + case "switch": return isTrue; case "int": var i = StripQuotes(expr); From e1a4c07288c974f1fa14bda0d1d62b0716c7a910 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:01:39 -0400 Subject: [PATCH 17/21] Harden script parsing and async execution --- .gitignore | 4 + .../PowerShellScriptParserTests.cs | 83 ++++++- .../Classes/ScriptCompatibilityValidator.cs | 14 +- .../Classes/ScriptFailureReport.cs | 2 +- .../Classes/ScriptOutputHandler.cs | 73 +----- .../Classes/ScriptParseResult.cs | 40 +++ .../Classes/ScriptRunDispatcher.cs | 86 +++++++ PaletteShellExtension/Classes/ScriptRunner.cs | 199 +++++++++++++-- .../Commands/ElevationIncompatibleCommand.cs | 3 +- .../Commands/IncompatibleScriptCommand.cs | 7 +- .../Commands/RunScriptCommand.cs | 44 ++-- .../Commands/UnknownHostCommand.cs | 19 ++ .../Commands/WarningDialog.cs | 28 +++ .../Forms/ScriptParameterForm.cs | 103 +++----- .../Pages/PaletteShellExtensionPage.cs | 71 +++++- PaletteShellExtension/Pages/ScriptRunPage.cs | 206 ++++++++++++++++ .../PowerShellScriptParser.cs | 228 ++++++++++++++++-- 17 files changed, 994 insertions(+), 216 deletions(-) create mode 100644 PaletteShellExtension/Classes/ScriptParseResult.cs create mode 100644 PaletteShellExtension/Classes/ScriptRunDispatcher.cs create mode 100644 PaletteShellExtension/Commands/UnknownHostCommand.cs create mode 100644 PaletteShellExtension/Commands/WarningDialog.cs create mode 100644 PaletteShellExtension/Pages/ScriptRunPage.cs diff --git a/.gitignore b/.gitignore index 7e8409f..23fa14a 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,7 @@ BenchmarkDotNet.Artifacts/ /PaletteShellExtension/bundle_mapping.txt signing/ /PaletteShellExtension/*.msixbundle +/.github/skills +/.agents/skills +/agent/skills +/PaletteShellExtension/IntegrationTestScripts/README.md diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs index 9108b11..04efb8b 100644 --- a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -516,14 +516,89 @@ public void NoParamBlock_ReturnsManifestWithNoParameters() } [Fact] - public void UnbalancedParamBlock_DoesNotThrow_AndYieldsNoParameters() + public void UnbalancedParamBlock_FailsClosed_WithDiagnostic() { + // A param block with no matching ')' is malformed. It must NOT parse as a parameterless + // script (which could launch fire-and-forget with the real params never reaching + // PowerShell) โ€” it fails closed so the caller shows a repair row. using var file = new TestScriptFile("param(\n [string]$Name"); - var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + var result = PowerShellScriptParser.TryParse(file.Path); - Assert.NotNull(manifest); - Assert.Empty(manifest!.Parameters); + Assert.Null(result.Manifest); + Assert.True(result.HasErrors); + Assert.Null(PowerShellScriptParser.TryParseManifest(file.Path)); + } + + [Fact] + public void UnbalancedAttributeBracket_FailsClosed() + { + // An attribute above param(...) with no closing ']' can't be scoped safely. + using var file = new TestScriptFile("[ConfirmBeforeRun('sure?'\nparam(\n [string]$Name\n)"); + + var result = PowerShellScriptParser.TryParse(file.Path); + + Assert.Null(result.Manifest); + Assert.True(result.HasErrors); + } + + [Fact] + public void UnbalancedSafetyAttribute_NoParamBlock_FailsClosed() + { + // A recognized safety attribute with no closing ']' and no param() block would otherwise + // be dropped silently, losing its elevation gate. Must fail closed. + using var file = new TestScriptFile("[RequiresElevation(\nRemove-Item C:\\temp -Recurse"); + + var result = PowerShellScriptParser.TryParse(file.Path); + + Assert.Null(result.Manifest); + Assert.True(result.HasErrors); + } + + [Fact] + public void OrdinaryBodyBrackets_NoParamBlock_DoNotFailClosed() + { + // Type accelerators and indexing must not be mistaken for malformed attributes. + using var file = new TestScriptFile("$a = @(1,2,3)\n[int]$b = $a[0]\nWrite-Output \"$b]\""); + + var result = PowerShellScriptParser.TryParse(file.Path); + + Assert.NotNull(result.Manifest); + Assert.False(result.HasErrors); + } + + [Fact] + public void UnknownOutputMode_FailsClosed() + { + using var file = new TestScriptFile("[ScriptOutput('Bogus')]\nparam()"); + + var result = PowerShellScriptParser.TryParse(file.Path); + + Assert.Null(result.Manifest); + Assert.True(result.HasErrors); + } + + [Fact] + public void NonNumericTimeout_FailsClosed() + { + using var file = new TestScriptFile("[ScriptTimeout('soon')]\nparam()"); + + var result = PowerShellScriptParser.TryParse(file.Path); + + Assert.Null(result.Manifest); + Assert.True(result.HasErrors); + } + + [Fact] + public void WellFormedScript_HasNoErrorsAndNotTruncated() + { + using var file = new TestScriptFile("[ScriptOutput('Clipboard')]\nparam(\n [string]$Name = 'x'\n)"); + + var result = PowerShellScriptParser.TryParse(file.Path); + + Assert.NotNull(result.Manifest); + Assert.False(result.HasErrors); + Assert.False(result.WasTruncated); } // ----- Path token expansion --------------------------------------------------------------- diff --git a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs index f0ef359..c1e5b72 100644 --- a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs +++ b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs @@ -12,6 +12,11 @@ internal enum ScriptCompatibilityKind /// The script asks to run elevated but also declares a capturing output mode โ€” an /// impossible combination (elevation can't redirect stdout). ElevationIncompatible, + + /// The script declares a [ScriptHost(...)] value that isn't a recognized + /// interpreter (not auto/pwsh/powershell). Caught at discovery so it never runs under the + /// wrong shell via a silent fallback. + UnknownHost, } /// @@ -22,7 +27,8 @@ internal enum ScriptCompatibilityKind internal readonly record struct ScriptCompatibility( ScriptCompatibilityKind Kind, string? RequiredVersion = null, - bool TooNew = false) + bool TooNew = false, + string? BadHost = null) { private static readonly ScriptCompatibility Compatible = new(ScriptCompatibilityKind.Ok); @@ -34,6 +40,12 @@ public static ScriptCompatibility Validate(ScriptManifest? manifest) if (!AppVersion.IsCompatible(manifest.MinVersion, manifest.MaxVersion, out var required, out var tooNew)) return new ScriptCompatibility(ScriptCompatibilityKind.RequiresUpdate, required!.ToString(), tooNew); + // A declared host that parses to Unknown is a manifest error: block it here rather than + // let the runner pick an interpreter the script never asked for. Null means "no override" + // (the configured default applies), which is always a valid token. + if (manifest.Host is not null && ScriptRunner.ParseHost(manifest.Host) == ScriptRunner.ShellHost.Unknown) + return new ScriptCompatibility(ScriptCompatibilityKind.UnknownHost, BadHost: manifest.Host); + if (ScriptElevation.IsElevatedOutputIncompatible(manifest)) return new ScriptCompatibility(ScriptCompatibilityKind.ElevationIncompatible); diff --git a/PaletteShellExtension/Classes/ScriptFailureReport.cs b/PaletteShellExtension/Classes/ScriptFailureReport.cs index 7acf0be..677db21 100644 --- a/PaletteShellExtension/Classes/ScriptFailureReport.cs +++ b/PaletteShellExtension/Classes/ScriptFailureReport.cs @@ -56,7 +56,7 @@ public static string Build(string scriptPath, string host, string args, ScriptRu sb.AppendLine(culture, $"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine(); sb.AppendLine(culture, $"Script: {scriptPath}"); - sb.AppendLine(culture, $"Shell: {ScriptRunner.ResolveShell(host)}"); + sb.AppendLine(culture, $"Shell: {ScriptRunner.DescribeShell(host)}"); sb.AppendLine(culture, $"Args: {(string.IsNullOrWhiteSpace(args) ? "(none)" : RedactArgs(args))}"); sb.AppendLine(culture, $"Outcome: {DescribeOutcome(result)}"); if (result?.DurationMs is { } duration) diff --git a/PaletteShellExtension/Classes/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs index f833e58..b20a5de 100644 --- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs +++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs @@ -1,5 +1,4 @@ using Microsoft.CommandPalette.Extensions.Toolkit; -using PaletteShellExtension.Commands; using System; using System.Diagnostics; using System.IO; @@ -8,74 +7,14 @@ namespace PaletteShellExtension.Classes; /// -/// Turns a script's captured stdout into a according to its -/// declared [ScriptOutput(...)] mode. Markdown is intentionally not handled here โ€” -/// it renders into a page and is routed by the caller before this is reached. +/// Low-level helpers for acting on a script's captured stdout per its declared +/// [ScriptOutput(...)] mode โ€” set the clipboard, open a target, decide whether a target is +/// safe to open unprompted. The mode dispatch itself lives in , +/// which runs on the async execution surfaces (never on the host's blocking COM call). /// internal static class ScriptOutputHandler { - public static CommandResult ToResult(string? mode, string? output, string? fileExtension = null, string? fileBaseName = null) - { - switch (mode?.Trim().ToLowerInvariant()) - { - case "clipboard": - if (!string.IsNullOrEmpty(output)) - { - TrySetClipboard(output); - return CommandResult.ShowToast("Copied to clipboard"); - } - return CommandResult.ShowToast("Script completed"); - - // Treat stdout as a URL, file, or folder and let Windows open it with the - // registered default app. The first non-empty line is the target so scripts can - // use Write-Output without worrying about a trailing newline. - case "open": - var target = GetOpenTarget(output); - if (target is null) - { - return CommandResult.ShowToast("Script completed without an open target"); - } - - // Only http(s) URLs and existing files/folders open without a prompt. Anything - // else โ€” custom protocols (ms-settings:, shell:), file://, .lnk shortcuts, - // unknown targets โ€” launches arbitrary handlers, so confirm with the exact - // target shown before letting Windows resolve it. - if (IsSafeOpenTarget(target)) - { - return OpenTarget(target); - } - - return CommandResult.Confirm(new ConfirmationArgs - { - Title = "Open script output?", - Description = $"This script wants to open:\n\n{target}\n\nThis isn't a web link or a file on disk โ€” it may launch another app or system handler. Open it?", - PrimaryCommand = new CallbackCommand("Open", () => OpenTarget(target)), - IsPrimaryCommandCritical = true, - }); - - // Write stdout to a temp file and open it in the user's editor. Useful for - // output that's too large or structured to be readable in a toast. - case "file": - if (!string.IsNullOrEmpty(output)) - { - EditorLauncher.OpenContent(output, fileExtension, fileBaseName); - return CommandResult.ShowToast("Opened output in editor"); - } - return CommandResult.ShowToast("Script completed"); - - // Run silently: confirm completion without surfacing the output. - case "none": - return CommandResult.ShowToast("Script completed"); - - // "toast" (and any unrecognized value) surfaces the captured output. - default: - return !string.IsNullOrEmpty(output) - ? CommandResult.ShowToast(output) - : CommandResult.ShowToast("Script completed"); - } - } - - private static CommandResult OpenTarget(string target) + internal static CommandResult OpenTarget(string target) { try { @@ -129,7 +68,7 @@ internal static bool IsSafeOpenTarget(string target) .FirstOrDefault(line => line.Length > 0); } - private static void TrySetClipboard(string text) + internal static void TrySetClipboard(string text) { try { diff --git a/PaletteShellExtension/Classes/ScriptParseResult.cs b/PaletteShellExtension/Classes/ScriptParseResult.cs new file mode 100644 index 0000000..019d634 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptParseResult.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; + +namespace PaletteShellExtension.Classes; + +/// How serious a is. +public enum ScriptParseSeverity +{ + /// Something was ignored, but the manifest is still safe to run + /// (e.g. an invalid [ScriptIcon(...)] glyph fell back to the default). + Warning, + + /// The metadata could not be understood well enough to run the script safely. + /// Any error makes the parse fail closed: + /// is null and the script must be surfaced as needing repair rather than executed. + Error, +} + +/// A single problem found while parsing a script's metadata. +public sealed record ScriptParseDiagnostic(ScriptParseSeverity Severity, string Message); + +/// +/// Outcome of parsing a script's PaletteShell metadata. Replaces the old nullable-manifest +/// return so callers can tell "no metadata, but fine to run" apart from "metadata is malformed โ€” +/// do not run." A non-empty set of diagnostics means the +/// parser failed closed: is null and the script should be shown as +/// a disabled repair row instead of executed. +/// +public sealed record ScriptParseResult( + ScriptManifest? Manifest, + IReadOnlyList Diagnostics, + bool WasTruncated) +{ + /// True when the metadata was malformed enough that the script must not run. + public bool HasErrors => Diagnostics.Any(d => d.Severity == ScriptParseSeverity.Error); + + /// The first blocking message, for a repair row's subtitle; null when none. + public string? FirstError => + Diagnostics.FirstOrDefault(d => d.Severity == ScriptParseSeverity.Error)?.Message; +} diff --git a/PaletteShellExtension/Classes/ScriptRunDispatcher.cs b/PaletteShellExtension/Classes/ScriptRunDispatcher.cs new file mode 100644 index 0000000..d2af833 --- /dev/null +++ b/PaletteShellExtension/Classes/ScriptRunDispatcher.cs @@ -0,0 +1,86 @@ +namespace PaletteShellExtension.Classes; + +/// +/// Decides what to do with a completed script run according to its declared +/// [ScriptOutput(...)] mode, and performs the terminal side effect (set the clipboard, +/// open the output in an editor, open a safe target). It returns a surface-agnostic +/// so both the async (a list surface) +/// and the parameter form's in-place render (a Markdown surface) share one set of decisions. +/// +/// This is the async counterpart of the old synchronous output handling: it only ever runs after +/// the script has finished on a background thread, never on the thread the host is blocked on for +/// its COM call. +/// +internal static class ScriptRunDispatcher +{ + /// + /// The result of dispatching a successful run. The non-interactive side effect (clipboard, + /// editor, safe open) has already happened by the time this is returned. + /// is a short human message (for Markdown modes it is the rendered body). + /// , when set, is text an interactive surface can offer to (re)copy. + /// , when set, is an open target that must be confirmed before + /// launching (a non-web, non-file path) โ€” nothing has been opened yet. + /// + internal readonly record struct RunOutcome(string Status, string? CopyValue, string? UnsafeOpenTarget); + + /// Performs the declared output effect for a completed, successful run and returns + /// what to show. is the captured stdout (null for elevated runs, + /// which can't be captured). + public static RunOutcome Apply(ScriptManifest manifest, string? output, string scriptName) + { + switch (manifest.Output?.Trim().ToLowerInvariant()) + { + case "clipboard": + if (!string.IsNullOrEmpty(output)) + { + ScriptOutputHandler.TrySetClipboard(output); + return new RunOutcome("Copied to clipboard", output, null); + } + return new RunOutcome("Script completed", null, null); + + // Write stdout to a temp file and open it in the user's editor. + case "file": + if (!string.IsNullOrEmpty(output)) + { + EditorLauncher.OpenContent(output, manifest.FileExtension, scriptName); + return new RunOutcome("Opened output in editor", null, null); + } + return new RunOutcome("Script completed", null, null); + + // Treat the first non-empty line of stdout as a URL/file/folder and let Windows open + // it. Web links and real files/folders open unprompted; anything else is returned as + // an UnsafeOpenTarget so the caller can confirm the exact target first. + case "open": + var target = ScriptOutputHandler.GetOpenTarget(output); + if (target is null) + { + return new RunOutcome("Script completed without an open target", null, null); + } + if (ScriptOutputHandler.IsSafeOpenTarget(target)) + { + ScriptOutputHandler.OpenTarget(target); + return new RunOutcome($"Opened {target}", null, null); + } + return new RunOutcome($"Open {target}?", null, target); + + // Run silently: report completion without surfacing output. + case "none": + return new RunOutcome("Script completed", null, null); + + // Only reached on the parameter form's in-place path (no-parameter Markdown routes to + // ScriptMarkdownPage). The raw stdout is the Markdown body to render. + case "markdown": + return new RunOutcome( + string.IsNullOrWhiteSpace(output) ? "_Script completed with no output._" : output!, + null, + null); + + // "toast" (and any unrecognized value) surfaces the captured output. + default: + var value = output?.Trim(); + return string.IsNullOrEmpty(value) + ? new RunOutcome("Script completed", null, null) + : new RunOutcome(value!, value, null); + } + } +} diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index d359708..e86cedc 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -97,44 +97,215 @@ public static string DescribeFailure(ScriptResult result) } } - private static readonly Lazy PwshAvailable = new(() => CanResolveOnPath("pwsh.exe")); + /// + /// Validated interpreter selection. The raw manifest/settings token + /// ("auto"/"pwsh"/"powershell") is parsed into one of these so each case + /// gets an explicit policy instead of "anything that isn't 'powershell' means pwsh-or-fallback". + /// + public enum ShellHost + { + /// Prefer pwsh, fall back to Windows PowerShell. + Auto, + /// Require PowerShell 7; fail if it isn't installed. + Pwsh, + /// Require Windows PowerShell (powershell.exe). + WindowsPowerShell, + /// Unrecognized token โ€” the script is incompatible. + Unknown, + } + + /// Thrown when the declared host can't be satisfied (required interpreter missing, + /// or an unknown host token). Callers turn this into a start-failure the user can read. + public sealed class ShellResolutionException : Exception + { + public ShellResolutionException(string message) : base(message) { } + } + /// Parses a host token into . Null/blank is + /// (the manifest default). Any other unrecognized value is โ€” signalled, + /// never silently coerced to a working interpreter. + public static ShellHost ParseHost(string? host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return ShellHost.Auto; + } + + return host.Trim().ToLowerInvariant() switch + { + "auto" => ShellHost.Auto, + "pwsh" => ShellHost.Pwsh, + "powershell" => ShellHost.WindowsPowerShell, + _ => ShellHost.Unknown, + }; + } + + /// + /// Resolves the host token to a concrete interpreter path. + /// + /// Auto: pwsh, else Windows PowerShell. + /// Pwsh: pwsh only โ€” throws "PowerShell 7 is required but not installed" when missing, + /// rather than silently downgrading a script that declared a 7-only requirement. + /// WindowsPowerShell: powershell.exe only. + /// Unknown: throws โ€” the script is incompatible. + /// + /// Each call re-resolves against the current PATH and standard install locations, so an + /// interpreter installed after startup is picked up (no permanently cached result). + /// public static string ResolveShell(string? host) { - if (string.Equals(host, "powershell", StringComparison.OrdinalIgnoreCase)) + switch (ParseHost(host)) { - return "powershell.exe"; + case ShellHost.WindowsPowerShell: + return FindWindowsPowerShell() + ?? throw new ShellResolutionException("Windows PowerShell (powershell.exe) is required but was not found."); + + case ShellHost.Pwsh: + return FindPwsh() + ?? throw new ShellResolutionException("PowerShell 7 is required but not installed. Install it from https://aka.ms/powershell, or set the script host to 'auto'."); + + case ShellHost.Auto: + return FindPwsh() ?? FindWindowsPowerShell() + ?? throw new ShellResolutionException("No PowerShell interpreter (pwsh.exe or powershell.exe) was found."); + + default: + return throwUnknown(); } - // Default to PowerShell 7 (pwsh), but fall back to Windows PowerShell when it - // isn't installed so scripts still run on a stock machine. - return PwshAvailable.Value ? "pwsh.exe" : "powershell.exe"; + string throwUnknown() => + throw new ShellResolutionException($"Unknown script host '{host}'. Supported values are 'auto', 'pwsh', and 'powershell'."); } - private static bool CanResolveOnPath(string exe) + /// Best-effort interpreter name for display (the failure report) that never throws: + /// returns the resolved path when available, otherwise the interpreter that would be + /// used, or a marker for an unknown host. Kept separate from so + /// report generation can't fail on a missing/invalid interpreter. + public static string DescribeShell(string? host) + { + try + { + return ResolveShell(host); + } + catch (ShellResolutionException) + { + return ParseHost(host) switch + { + ShellHost.Pwsh => "pwsh.exe (not found)", + ShellHost.WindowsPowerShell => "powershell.exe (not found)", + ShellHost.Auto => "(no PowerShell found)", + _ => $"(unknown host '{host}')", + }; + } + } + + /// Finds pwsh.exe on PATH or in the standard PowerShell 7+ install locations. + private static string? FindPwsh() => FindExecutable("pwsh.exe", PwshInstallDirs()); + + /// Finds powershell.exe on PATH or in its fixed System32 location. + private static string? FindWindowsPowerShell() => FindExecutable("powershell.exe", WindowsPowerShellInstallDirs()); + + /// Standard install roots for PowerShell 7+: %ProgramFiles%\PowerShell\<version> + /// (both bitnesses) and the winget/WindowsApps shim location. + private static IEnumerable PwshInstallDirs() + { + foreach (var pf in new[] + { + Environment.GetEnvironmentVariable("ProgramW6432"), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + }) + { + if (string.IsNullOrWhiteSpace(pf)) + { + continue; + } + + var root = Path.Combine(pf, "PowerShell"); + string[] versionDirs; + try + { + versionDirs = Directory.Exists(root) ? Directory.GetDirectories(root) : Array.Empty(); + } + catch (Exception) + { + versionDirs = Array.Empty(); + } + + foreach (var dir in versionDirs) + { + yield return dir; + } + } + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + yield return Path.Combine(localAppData, "Microsoft", "WindowsApps"); + } + } + + /// Windows PowerShell ships at a single fixed path under the OS directory. + private static IEnumerable WindowsPowerShellInstallDirs() + { + var windir = Environment.GetEnvironmentVariable("SystemRoot") + ?? Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (!string.IsNullOrWhiteSpace(windir)) + { + yield return Path.Combine(windir, "System32", "WindowsPowerShell", "v1.0"); + } + } + + /// Resolves to a full path by scanning the current PATH first, + /// then (standard install locations). Returns null when not found. + /// Resolved fresh on every call โ€” nothing is cached, so a newly installed interpreter is seen. + private static string? FindExecutable(string exe, IEnumerable extraDirs) { var path = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrEmpty(path)) + if (!string.IsNullOrEmpty(path)) { - return false; + foreach (var dir in path.Split(Path.PathSeparator)) + { + if (Contains(dir, exe, out var hit)) + { + return hit; + } + } + } + + foreach (var dir in extraDirs) + { + if (Contains(dir, exe, out var hit)) + { + return hit; + } } - foreach (var dir in path.Split(Path.PathSeparator)) + return null; + + static bool Contains(string? dir, string exe, out string? fullPath) { + fullPath = null; + if (string.IsNullOrWhiteSpace(dir)) + { + return false; + } + try { - if (!string.IsNullOrWhiteSpace(dir) && File.Exists(Path.Combine(dir, exe))) + var candidate = Path.Combine(dir, exe); + if (File.Exists(candidate)) { + fullPath = candidate; return true; } } catch (Exception) { - // Ignore malformed PATH entries. + // Ignore malformed PATH entries / install paths. } - } - return false; + return false; + } } /// diff --git a/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs b/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs index 0e56c86..89e1e7f 100644 --- a/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs +++ b/PaletteShellExtension/Commands/ElevationIncompatibleCommand.cs @@ -13,6 +13,7 @@ internal sealed partial class ElevationIncompatibleCommand : InvokableCommand public override string Name => "Can't run elevated with output"; public override IconInfo Icon => new(""); // Warning + // A toast truncated the message; show the full reason in a dialog the user can read. public override CommandResult Invoke() => - CommandResult.ShowToast(ScriptElevation.IncompatibleReason()); + WarningDialog.Show("Can't run elevated with output", ScriptElevation.IncompatibleReason()); } diff --git a/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs b/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs index 7432a67..a8af512 100644 --- a/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs +++ b/PaletteShellExtension/Commands/IncompatibleScriptCommand.cs @@ -13,8 +13,9 @@ internal sealed partial class IncompatibleScriptCommand(string requiredVersion, public override string Name => "Requires an update"; public override IconInfo Icon => new(""); // Warning + // A toast truncated the message; show the full reason in a dialog the user can read. public override CommandResult Invoke() => - CommandResult.ShowToast(tooNew - ? $"This script requires PaletteShell v{requiredVersion} or earlier (you have v{installedVersion})" - : $"This script requires PaletteShell v{requiredVersion} or later (you have v{installedVersion})"); + WarningDialog.Show("Requires an update", tooNew + ? $"This script requires PaletteShell v{requiredVersion} or earlier (you have v{installedVersion})." + : $"This script requires PaletteShell v{requiredVersion} or later (you have v{installedVersion})."); } diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index 7cc8c41..2f8f7d1 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -1,18 +1,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit; using PaletteShellExtension.Classes; -using PaletteShellExtension.Forms; -using PaletteShellExtension.Pages; using System.IO; namespace PaletteShellExtension.Commands; +/// +/// Runs a no-parameter script fire-and-forget: it has nothing to wait for or surface (output +/// mode None with no declared timeout), so it launches the script and reports completion +/// without ever blocking the host's COM call. Every waited mode (Toast/Clipboard/Open/File, or +/// None with a declared timeout) is routed to instead, which +/// runs asynchronously so the palette can't freeze on a slow script. +/// internal sealed partial class RunScriptCommand(string path, ScriptManifest? manifest) : InvokableCommand { private readonly ScriptManifest _manifest = manifest ?? new ScriptManifest(); public override string Name => $"Run {Path.GetFileNameWithoutExtension(path)}"; - public override IconInfo Icon => new(_manifest.IconGlyph ?? "๎Ÿƒ"); // Play; or map emoji Icon if you like + public override IconInfo Icon => new(_manifest.IconGlyph ?? ""); // Play; or map emoji Icon if you like public override CommandResult Invoke() { @@ -37,32 +42,11 @@ private CommandResult RunNow() { var plan = ScriptExecutionService.CreatePlan(_manifest, path); - // "None" never surfaces output, so when there's also no declared timeout we can run - // fire-and-forget without waiting for/capturing stdout. Any other mode - // (Toast/Clipboard) needs the output, so we must wait even without a timeout. - if (plan.DeclaredTimeoutMs is null && !plan.SurfacesOutput) - { - ScriptExecutionService.RunFireAndForget(plan); - return CommandResult.ShowToast("Script completed"); - } - - // Wait so the declared output mode can be honored, falling back to a default - // timeout when the script didn't specify one. - var result = ScriptExecutionService.RunAndWait(plan); - - // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose - // "View details" opens the full failure report โ€” a toast is too small and too - // short-lived to explain what went wrong. - if (result == null || result.TimedOut || result.ExitCode != 0) - return ScriptFailurePresenter.ToCommandResult(path, plan.Host, "", result); - - // Elevated scripts can't have their output captured, so suppress output handling. - var output = plan.CaptureOutput ? result.StandardOutput : null; - - return ScriptOutputHandler.ToResult( - _manifest.Output, - output, - _manifest.FileExtension, - Path.GetFileNameWithoutExtension(path)); + // Launch and forget: there's no output to capture and no declared timeout to honor, so a + // synchronous wait would only block the host for nothing. + var started = ScriptExecutionService.RunFireAndForget(plan); + return started + ? CommandResult.ShowToast("Script completed") + : ScriptFailurePresenter.ToCommandResult(path, plan.Host, "", null); } } diff --git a/PaletteShellExtension/Commands/UnknownHostCommand.cs b/PaletteShellExtension/Commands/UnknownHostCommand.cs new file mode 100644 index 0000000..c4c6454 --- /dev/null +++ b/PaletteShellExtension/Commands/UnknownHostCommand.cs @@ -0,0 +1,19 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PaletteShellExtension.Commands; + +/// +/// Stands in for a script's normal command when its [ScriptHost(...)] value isn't a +/// recognized interpreter (not auto/pwsh/powershell). Selecting the row explains the bad token +/// instead of silently running the script under a fallback shell it never asked for. +/// +internal sealed partial class UnknownHostCommand(string badHost) : InvokableCommand +{ + public override string Name => "Unknown script host"; + public override IconInfo Icon => new(""); // Warning + + // A toast truncated the message; show the full reason in a dialog the user can read. + public override CommandResult Invoke() => + WarningDialog.Show("Unknown script host", + $"Unknown script host '{badHost}'. Supported values are 'auto', 'pwsh', and 'powershell'."); +} diff --git a/PaletteShellExtension/Commands/WarningDialog.cs b/PaletteShellExtension/Commands/WarningDialog.cs new file mode 100644 index 0000000..3cde6b5 --- /dev/null +++ b/PaletteShellExtension/Commands/WarningDialog.cs @@ -0,0 +1,28 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using System; + +namespace PaletteShellExtension.Commands; + +/// +/// Shared presentation for the non-runnable warning rows (incompatible version, unknown host, +/// elevation/output conflict). The palette truncates a row's subtitle and a transient toast, so a +/// meaningful reason was lost before the user could read it. This surfaces the full text in a +/// dismissible confirmation dialog instead โ€” the same "show the whole thing" approach +/// uses for runtime failures. +/// +internal static class WarningDialog +{ + public static CommandResult Show(string title, string message) => + Show(title, message, "OK", () => CommandResult.Dismiss()); + + /// Same dialog with a custom primary action (e.g. "Open to fix" for a malformed + /// script), so the full reason is readable and the fix is one keypress away. + public static CommandResult Show(string title, string message, string primaryLabel, Func primaryAction) => + CommandResult.Confirm(new ConfirmationArgs + { + Title = title, + Description = message, + PrimaryCommand = new CallbackCommand(primaryLabel, primaryAction), + IsPrimaryCommandCritical = false, + }); +} diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index d2ba262..36c9f24 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -68,16 +68,14 @@ public override CommandResult SubmitForm(string inputs, string data) // Build argument line from form values (quoting centralized in ScriptArgumentBuilder). var argsLine = ScriptArgumentBuilder.BuildFromForm(_manifest.Parameters, obj); - // Markdown output renders in place on this page, so run it asynchronously and show a - // "Runningโ€ฆ" spinner while it works โ€” a slow script (e.g. an event-log query) no - // longer leaves the form looking frozen. Other modes surface a quick toast and are - // typically instant, so they keep the simple synchronous path. - var runsInPage = string.Equals(_manifest.Output, "Markdown", StringComparison.OrdinalIgnoreCase) - && _onMarkdown is not null; - - Func run = runsInPage - ? () => StartMarkdownRun(argsLine) - : () => Execute(argsLine); + // Every waited run happens on a background thread and renders its result (or performs + // its clipboard/open/file effect) in place, showing a "Runningโ€ฆ" spinner meanwhile โ€” so + // a slow script no longer freezes the form while the host is blocked on the submit COM + // call. Elevated runs can't capture output, so they launch fire-and-forget and report + // completion immediately. + Func run = _plan.RequiresAdmin + ? () => ExecuteElevated(argsLine) + : () => StartAsyncRun(argsLine); // Destructive scripts gate behind a confirmation dialog; only the dialog's // primary command runs the script (with the values already collected here). @@ -101,23 +99,22 @@ public override CommandResult SubmitForm(string inputs, string data) } } - /// Runs a Markdown-output script on a background thread so the UI thread isn't - /// blocked, signalling the page to show a "Runningโ€ฆ" spinner while it works and rendering the - /// result (or an error) in place when it finishes. - private CommandResult StartMarkdownRun(string argsLine) + /// Runs the script on a background thread so the host's submit COM call returns at + /// once, showing a "Runningโ€ฆ" spinner meanwhile and rendering the result in place when it + /// finishes. On success the declared output effect (clipboard/open/file) is performed and a + /// short status is shown; Markdown/Toast modes render their output. Never reached for elevated + /// runs (they can't capture output โ€” see ). + private CommandResult StartAsyncRun(string argsLine) { _onRunStarted?.Invoke(); - _ = Task.Run(() => + _ = Task.Run(async () => { string body; try { - // Markdown mode is never elevated (the compatibility gate blocks elevated + - // capturing output), so the plan's RequiresAdmin is false here. - var result = ScriptExecutionService.RunAndWait(_plan, argsLine); - - body = FormatInPageResult(result); + var result = await ScriptExecutionService.RunAsync(_plan, argsLine); + body = FormatAsyncResult(result); } catch (Exception ex) { @@ -126,7 +123,7 @@ private CommandResult StartMarkdownRun(string argsLine) try { - _onMarkdown!(body); + _onMarkdown?.Invoke(body); } finally { @@ -137,9 +134,11 @@ private CommandResult StartMarkdownRun(string argsLine) return CommandResult.KeepOpen(); } - /// Turns a run result into the Markdown body to render: failures and empty output - /// become a short note rather than a blank panel. - private static string FormatInPageResult(ScriptRunner.ScriptResult? result) + /// Turns a completed run into the Markdown body to render in place. Failures render + /// inline (with stderr); a success performs the declared output effect via + /// and shows its status (or the rendered output for + /// Markdown/Toast modes). + private string FormatAsyncResult(ScriptRunner.ScriptResult? result) { if (result is null) return "_Failed to start script._"; @@ -155,55 +154,19 @@ private static string FormatInPageResult(ScriptRunner.ScriptResult? result) : $"**Script failed with exit code {result.ExitCode}.**\n\n```\n{error}\n```"; } - if (string.IsNullOrWhiteSpace(result.StandardOutput)) - return "_Script completed with no output._"; - - return result.StandardOutput!; + var scriptName = System.IO.Path.GetFileNameWithoutExtension(_scriptPath); + return ScriptRunDispatcher.Apply(_manifest, result.StandardOutput, scriptName).Status; } - /// Runs the script with the already-built argument line and turns its result into - /// a per the declared output mode. - private CommandResult Execute(string argsLine) + /// Launches an elevated script fire-and-forget. Elevated scripts can't have their + /// output captured, so the routing gate only lets one reach here when its output is None โ€” + /// there's nothing to wait for or surface, so this returns immediately. + private CommandResult ExecuteElevated(string argsLine) { - try - { - // Elevated scripts can't capture output, so the routing gate only lets an elevated - // script reach here when its output is None. Launch it elevated fire-and-forget - // (runas honors ArgumentList/args) โ€” there's nothing to wait for or surface. - if (_plan.RequiresAdmin) - { - ScriptExecutionService.RunFireAndForget(_plan, argsLine); - return CommandResult.ShowToast("Script completed"); - } - - // Run script and wait for completion - var result = ScriptExecutionService.RunAndWait(_plan, argsLine); - - // Failures (couldn't start, timed out, non-zero exit) surface as a dialog whose - // "View details" opens the full failure report โ€” a toast is too small and too - // short-lived to explain what went wrong. - if (result == null || result.TimedOut || result.ExitCode != 0) - return ScriptFailurePresenter.ToCommandResult(_scriptPath, _plan.Host, argsLine, result); - - // Markdown output - render the result in place instead of a toast. - var wantsMarkdown = string.Equals(_manifest.Output, "Markdown", StringComparison.OrdinalIgnoreCase); - if (wantsMarkdown && _onMarkdown is not null) - { - _onMarkdown(result.StandardOutput ?? ""); - return CommandResult.KeepOpen(); - } - - // Clipboard / File / Status / Toast / None are handled identically to the no-parameter path. - return ScriptOutputHandler.ToResult( - _manifest.Output, - result.StandardOutput, - _manifest.FileExtension, - System.IO.Path.GetFileNameWithoutExtension(_scriptPath)); - } - catch (Exception) - { - return CommandResult.GoBack(); - } + var started = ScriptExecutionService.RunFireAndForget(_plan, argsLine); + return started + ? CommandResult.ShowToast("Script completed") + : ScriptFailurePresenter.ToCommandResult(_scriptPath, _plan.Host, argsLine, null); } /// Returns the label/name of each required parameter whose submitted value is diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index e3c8524..a1dea86 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -31,7 +31,7 @@ internal sealed partial class PaletteShellExtensionPage : ListPage private IListItem[]? _cachedItems; private readonly ConcurrentDictionary _manifestCache = new(StringComparer.OrdinalIgnoreCase); - private sealed record CachedManifestEntry(long Length, DateTime LastWriteTimeUtc, ScriptManifest? Manifest); + private sealed record CachedManifestEntry(long Length, DateTime LastWriteTimeUtc, ScriptParseResult Result); // Set when the configured scripts folder couldn't be created or scanned (unplugged // USB drive, offline share, changed drive letter). GetItems() surfaces it instead of @@ -383,7 +383,38 @@ public override IListItem[] GetItems() var path = file.FullName; try { - var manifest = GetCachedManifest(file); + var parseResult = GetCachedManifest(file); + + // Metadata was malformed enough that the parser failed closed. Show a disabled + // repair row that opens the script for editing instead of running a partially + // understood script (which could launch fire-and-forget and report false success). + if (parseResult.HasErrors) + { + var repairPinned = pins.IsPinned(path); + var repairTitle = Path.GetFileNameWithoutExtension(path); + // The subtitle truncates the parse error; Enter shows the full reason in a + // dialog whose primary action opens the script to fix it. + scriptResults[i] = (repairPinned, repairTitle, new ListItem(new CallbackCommand("View error", () => + WarningDialog.Show( + $"Couldn't load {Path.GetFileName(path)}", + parseResult.FirstError ?? "The script's metadata couldn't be parsed.", + "Open to fix", + () => + { + EditorLauncher.Open(path); + return CommandResult.Dismiss(); + })) + ) + { + Title = repairTitle, + Subtitle = $"โš  Couldn't load this script โ€” {parseResult.FirstError} Open to fix ({Path.GetFileName(path)})", + Icon = new IconInfo(""), // Warning + MoreCommands = BuildContextCommands(path, pins) + }); + return; + } + + var manifest = parseResult.Manifest; var title = manifest?.Title ?? Path.GetFileNameWithoutExtension(path); var subtitle = manifest?.Description ?? path; @@ -406,6 +437,19 @@ public override IListItem[] GetItems() return; } + if (compat.Kind == ScriptCompatibilityKind.UnknownHost) + { + var unknownHostPinned = pins.IsPinned(path); + scriptResults[i] = (unknownHostPinned, title, new ListItem(new UnknownHostCommand(compat.BadHost!)) + { + Title = title, + Subtitle = $"โš  Unknown script host '{compat.BadHost}' โ€” use auto, pwsh, or powershell", + Icon = new IconInfo(""), // Warning + MoreCommands = BuildContextCommands(path, pins) + }); + return; + } + if (compat.Kind == ScriptCompatibilityKind.ElevationIncompatible) { var elevationPinned = pins.IsPinned(path); @@ -456,9 +500,18 @@ public override IListItem[] GetItems() // a calculator shows an answer. command = new ScriptResultPage(path, manifest, plan!); } + else if (plan is not null && (plan.DeclaredTimeoutMs is not null || plan.SurfacesOutput)) + { + // No parameters, but a waited mode (Toast/Clipboard/Open/File, or None with a + // declared timeout): run on the async ScriptRunPage so a slow script doesn't + // freeze the host on its blocking COM call. The page shows progress, runs the + // script off-thread, then dispatches the clipboard/toast/file/open behavior. + command = new ScriptRunPage(path, manifest!, plan); + } else { - // No parameters - run script directly + // No parameters, nothing to wait for or surface (None output with no declared + // timeout, or no manifest at all) - launch fire-and-forget. command = new RunScriptCommand(path, manifest); } @@ -531,7 +584,7 @@ public override IListItem[] GetItems() // Takes the FileInfo from RefreshFiles' enumeration so the size/write-time cache check // doesn't re-stat the file. The snapshot being from scan time is fine: picking up // between-reload edits was never promised โ€” "Reload scripts" is the refresh point. - private ScriptManifest? GetCachedManifest(FileInfo info) + private ScriptParseResult GetCachedManifest(FileInfo info) { var path = info.FullName; try @@ -540,18 +593,18 @@ public override IListItem[] GetItems() && cached.Length == info.Length && cached.LastWriteTimeUtc == info.LastWriteTimeUtc) { - return cached.Manifest; + return cached.Result; } - var manifest = PowerShellScriptParser.TryParseManifest(path); - _manifestCache[path] = new CachedManifestEntry(info.Length, info.LastWriteTimeUtc, manifest); - return manifest; + var result = PowerShellScriptParser.TryParse(path); + _manifestCache[path] = new CachedManifestEntry(info.Length, info.LastWriteTimeUtc, result); + return result; } catch (Exception ex) { Log.Warn($"Failed to stat manifest for '{path}': {ex.Message}"); _manifestCache.TryRemove(path, out _); - return PowerShellScriptParser.TryParseManifest(path); + return PowerShellScriptParser.TryParse(path); } } diff --git a/PaletteShellExtension/Pages/ScriptRunPage.cs b/PaletteShellExtension/Pages/ScriptRunPage.cs new file mode 100644 index 0000000..7754e80 --- /dev/null +++ b/PaletteShellExtension/Pages/ScriptRunPage.cs @@ -0,0 +1,206 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using PaletteShellExtension.Commands; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PaletteShellExtension.Pages; + +/// +/// Common asynchronous execution page for every "waited" no-parameter output mode โ€” Toast, +/// Clipboard, Open, File, and None with a declared timeout. The synchronous Invoke() runner +/// blocks the thread the host is making its COM call on for the whole run (up to ten minutes), +/// which freezes the palette. This page instead navigates immediately, shows a progress spinner, +/// runs the script on a background thread via , and +/// dispatches the clipboard/toast/file/open/result behavior when it finishes. Navigating away +/// disposes the page, which cancels the run and kills the child process. +/// +internal sealed partial class ScriptRunPage : ListPage, IDisposable +{ + private readonly string _scriptPath; + private readonly ScriptManifest _manifest; + private readonly ScriptExecutionPlan _plan; + + private IListItem[] _items = []; + private bool _started; + + // Set while a run is in flight so an incidental duplicate GetItems doesn't launch a second run. + private bool _running; + + // Set once a destructive script's confirmation has been accepted, so the run starts only then. + private bool _confirmed; + + // Cancelled (and the child process killed) when the page is disposed on navigate-away. + private readonly CancellationTokenSource _cts = new(); + + public ScriptRunPage(string scriptPath, ScriptManifest manifest, ScriptExecutionPlan plan) + { + _scriptPath = scriptPath; + _manifest = manifest; + _plan = plan; + + Title = manifest.Title ?? Path.GetFileNameWithoutExtension(scriptPath); + Name = "Run"; + Icon = new(manifest.IconGlyph ?? ""); + Id = $"ScriptRun_{Path.GetFileNameWithoutExtension(scriptPath)}"; + IsLoading = true; + } + + private bool NeedsConfirmation => !string.IsNullOrWhiteSpace(_manifest.ConfirmMessage); + + public override IListItem[] GetItems() + { + if (!_started) + { + _started = true; + + // Destructive scripts gate behind a confirmation dialog first; the run only starts once + // the user accepts. Everything else starts immediately on first fetch. + if (NeedsConfirmation && !_confirmed) + { + IsLoading = false; + _items = [BuildConfirmItem()]; + } + else + { + Start(); + } + } + + return _items; + } + + // A single row that surfaces the destructive-run confirmation. Enter shows the native confirm + // dialog; accepting it starts the run in place. + private ListItem BuildConfirmItem() + { + var scriptName = Path.GetFileNameWithoutExtension(_scriptPath); + return new ListItem(new CallbackCommand($"Run {scriptName}", () => CommandResult.Confirm(new ConfirmationArgs + { + Title = $"Run {scriptName}?", + Description = _manifest.ConfirmMessage ?? "", + PrimaryCommand = new CallbackCommand($"Run {scriptName}", () => + { + _confirmed = true; + Start(); + return CommandResult.KeepOpen(); + }), + IsPrimaryCommandCritical = true, + }))) + { + Title = $"Run {scriptName}?", + Subtitle = _manifest.ConfirmMessage ?? "", + Icon = new IconInfo(_manifest.IconGlyph ?? ""), + }; + } + + private void Start() + { + if (_running) + { + return; + } + + _running = true; + IsLoading = true; + _ = Task.Run(Execute); + } + + private async Task Execute() + { + try + { + var result = await ScriptExecutionService.RunAsync(_plan, cancellationToken: _cts.Token); + _items = BuildItems(result); + } + catch (OperationCanceledException) + { + // The page was navigated away from mid-run; the child was killed. Nothing to render. + return; + } + catch (Exception ex) + { + _items = [Message($"Error running script: {ex.Message}")]; + } + finally + { + _running = false; + IsLoading = false; + if (!_cts.IsCancellationRequested) + { + RaiseItemsChanged(); + } + } + } + + private IListItem[] BuildItems(ScriptRunner.ScriptResult? result) + { + // Failures get an actionable row (Enter opens the full failure report) instead of an inert + // message, so the user can see the whole error rather than a summary. + if (result is null || result.TimedOut || result.ExitCode != 0) + { + return [ScriptFailurePresenter.ToListItem(_scriptPath, _plan.Host, "", result)]; + } + + // Elevated runs can't capture output, so there's nothing to surface beyond completion. + var output = _plan.CaptureOutput ? result.StandardOutput : null; + var outcome = ScriptRunDispatcher.Apply(_manifest, output, Path.GetFileNameWithoutExtension(_scriptPath)); + + // A non-web, non-file open target: confirm the exact target before letting Windows launch + // whatever handler it resolves to. + if (outcome.UnsafeOpenTarget is { } target) + { + return + [ + new ListItem(new CallbackCommand("Open", () => + { + ScriptOutputHandler.OpenTarget(target); + return CommandResult.KeepOpen(); + })) + { + Title = target, + Subtitle = "Not a web link or a file on disk โ€” press Enter to open it", + Icon = new IconInfo(""), // Warning + }, + ]; + } + + // Clipboard/Toast leave something worth copying again; show it as a copyable result row. + if (outcome.CopyValue is { } value) + { + return + [ + new ListItem(new CopyValueCommand(value)) + { + Title = outcome.Status, + Subtitle = "Press Enter to copy to the clipboard", + Icon = new IconInfo(_manifest.IconGlyph ?? ""), + }, + ]; + } + + return [Message(outcome.Status)]; + } + + private static ListItem Message(string text) => new(new NoOpCommand()) { Title = text }; + + public void Dispose() + { + try + { + if (!_cts.IsCancellationRequested) + { + _cts.Cancel(); + } + } + catch (ObjectDisposedException) + { + // Already disposed โ€” nothing to cancel. + } + + _cts.Dispose(); + } +} diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index 5ab13c1..ef3e238 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -29,11 +29,19 @@ internal static partial class PowerShellScriptParser // metadata extends past this is pathological and simply parses as metadata-less. private const int MetadataReadLimitChars = 64 * 1024; - public static ScriptManifest? TryParseManifest(string ps1Path) + /// + /// Parses a script's metadata, returning both any manifest and the diagnostics gathered along + /// the way. When the metadata is malformed (unbalanced param() or attribute brackets, a + /// critical attribute with an invalid value, an ununderstood parameter, or metadata truncated + /// mid-structure by the read cap) the result fails closed: its + /// is null and callers must surface the script as + /// needing repair rather than run a partially understood script. + /// + public static ScriptParseResult TryParse(string ps1Path) { if (!File.Exists(ps1Path)) { - return null; + return new ScriptParseResult(null, [], false); } try @@ -41,27 +49,46 @@ internal static partial class PowerShellScriptParser // A UTF-8 file never decodes to more chars than it has bytes, so sizing the // buffer by file length keeps small scripts (the normal case) from paying for // the full cap. - var maxChars = (int)Math.Min(new FileInfo(ps1Path).Length, MetadataReadLimitChars); + var byteLength = new FileInfo(ps1Path).Length; + var maxChars = (int)Math.Min(byteLength, MetadataReadLimitChars); using var reader = new StreamReader(ps1Path, Encoding.UTF8); var buffer = new char[maxChars]; var read = reader.ReadBlock(buffer, 0, buffer.Length); var content = new string(buffer, 0, read); - return ParseManifestFromContent(content, Path.GetFileNameWithoutExtension(ps1Path)); + + // Truncation is possible only when the file has more bytes than the char cap (UTF-8 + // never decodes to more chars than bytes). Whether it actually matters is decided in + // ParseContent: a body cut off after a complete metadata header is fine, but a + // param()/attribute block cut off mid-structure fails closed. + var wasTruncated = byteLength > MetadataReadLimitChars; + return ParseContent(content, Path.GetFileNameWithoutExtension(ps1Path), wasTruncated); } catch (Exception ex) { Log.Warn($"Failed to parse manifest for '{ps1Path}': {ex.Message}"); - return null; + return new ScriptParseResult(null, [], false); } } + /// Convenience shim over for callers that only want the + /// manifest and treat a fail-closed result the same as "no metadata": returns null both when + /// the file is unreadable and when the metadata was malformed. + public static ScriptManifest? TryParseManifest(string ps1Path) => TryParse(ps1Path).Manifest; + /// Parses a manifest from raw script text rather than a file on disk โ€” used for /// embedded sample-script resources, which aren't written to disk until after this decides /// whether they should be. stands in for the file name - /// when there's no .SYNOPSIS and (unlike ) no path to + /// when there's no .SYNOPSIS and (unlike ) no path to /// derive one from. - internal static ScriptManifest ParseManifestFromContent(string content, string fallbackTitle = "") + internal static ScriptManifest? ParseManifestFromContent(string content, string fallbackTitle = "") + => ParseContent(content, fallbackTitle).Manifest; + + /// Core parser shared by (file) and + /// (raw text). reports + /// whether the caller had to cut the input at the metadata read cap. + internal static ScriptParseResult ParseContent(string content, string fallbackTitle = "", bool wasTruncated = false) { + var diagnostics = new List(); var help = ParseCommentHelp(content); var manifest = new ScriptManifest @@ -80,20 +107,56 @@ internal static ScriptManifest ParseManifestFromContent(string content, string f string? paramBlock = null; if (paramKeyword.Success) { + // ParamKeywordRegex matches through the '(', so IndexOf always finds it here. var openParen = cleaned.IndexOf('(', paramKeyword.Index); if (openParen >= 0) { paramBlock = ExtractBalanced(cleaned, openParen, '(', ')'); + if (paramBlock is null) + { + // No matching ')'. Rather than silently treat this as a parameterless script + // (which could launch a script whose real params never reached PowerShell), + // fail closed. If the read was capped, the cap is the likely cause. + diagnostics.Add(new ScriptParseDiagnostic( + ScriptParseSeverity.Error, + wasTruncated + ? "The param(...) block runs past the part of the file PaletteShell reads โ€” its metadata is too large." + : "The param(...) block is missing its closing ')'.")); + } } + attributeZone = cleaned[..paramKeyword.Index]; + + // An unbalanced attribute bracket in the header means the [Script*] scan can't be + // trusted โ€” a mis-scoped bracket could swallow a confirmation/elevation/output + // requirement โ€” so fail closed too. Only checked when a param keyword bounds the + // header; without one the "zone" is the whole body, whose ordinary array/index + // brackets aren't ours to balance. + if (paramBlock is not null && HasUnbalancedBrackets(attributeZone)) + { + diagnostics.Add(new ScriptParseDiagnostic( + ScriptParseSeverity.Error, + "A [ ... ] attribute above param(...) is missing its closing bracket.")); + } } else { + // No param keyword, so the "zone" is the whole body and a general bracket-balance + // check would false-positive on ordinary code ([int[]], $a[0], here-strings). Still + // guard the safety-relevant case: a recognized [Script*]/Requires* attribute with no + // closing ']' would otherwise be dropped silently (e.g. an unbalanced + // [RequiresElevation(...] losing its elevation gate), so fail closed on that alone. attributeZone = cleaned; + if (HasUnbalancedRecognizedAttribute(attributeZone)) + { + diagnostics.Add(new ScriptParseDiagnostic( + ScriptParseSeverity.Error, + "A [ ... ] attribute is missing its closing bracket.")); + } } // Script-level [Script*] attributes live before the param keyword. - ParseScriptAttributes(attributeZone, manifest); + ParseScriptAttributes(attributeZone, manifest, diagnostics); // A script that predates [ScriptVersion(...)] (or simply omits it) is assumed to be // at the baseline version rather than "no version" - keeps every manifest comparable @@ -127,10 +190,118 @@ internal static ScriptManifest ParseManifestFromContent(string content, string f { manifest.Parameters.Add(parameter); } + else + { + // A non-empty declaration with no recognizable $variable โ€” fail closed rather + // than run the script with a parameter silently dropped from its form. + diagnostics.Add(new ScriptParseDiagnostic( + ScriptParseSeverity.Error, + "A parameter declaration couldn't be understood.")); + } } } - return manifest; + // Fail closed: any error means the metadata is only partially understood, so hand back + // diagnostics with no manifest instead of a runnable one. + var manifestOrNull = diagnostics.Any(d => d.Severity == ScriptParseSeverity.Error) + ? null + : manifest; + return new ScriptParseResult(manifestOrNull, diagnostics, wasTruncated); + } + + /// Quote-aware check that every [ ] and ( ) in is + /// balanced. Used to reject a malformed attribute header rather than mis-scope its brackets. + private static bool HasUnbalancedBrackets(string s) + { + int bracket = 0, paren = 0; + char quote = '\0'; + + foreach (var c in s) + { + if (quote != '\0') + { + if (c == quote) + { + quote = '\0'; + } + continue; + } + + switch (c) + { + case '\'' or '"': + quote = c; + break; + case '[': + bracket++; + break; + case ']': + if (--bracket < 0) + { + return true; + } + break; + case '(': + paren++; + break; + case ')': + if (--paren < 0) + { + return true; + } + break; + } + } + + return bracket != 0 || paren != 0; + } + + /// + /// Quote-aware check for a recognized [Script*]/Requires* attribute whose + /// opening [ has no matching ]. Unlike this + /// is safe to run over a whole script body: it only reacts to brackets whose leading + /// identifier is one we act on, so ordinary type accelerators ([int]) and indexing + /// ($a[0]) can't trigger it. Used on the no-param path, where the "attribute zone" is + /// the entire body and a general balance check would false-positive. + /// + private static bool HasUnbalancedRecognizedAttribute(string s) + { + char quote = '\0'; + + for (int i = 0; i < s.Length; i++) + { + var c = s[i]; + if (quote != '\0') + { + if (c == quote) + { + quote = '\0'; + } + continue; + } + + if (c is '\'' or '"') + { + quote = c; + } + else if (c == '[') + { + // Read the identifier immediately after '[' and only bother matching brackets + // when it's an attribute we recognize. + int j = i + 1; + while (j < s.Length && (char.IsLetterOrDigit(s[j]) || s[j] == '_')) + { + j++; + } + + if (j > i + 1 && KnownAttributes.Contains(s[(i + 1)..j]) && ExtractBalanced(s, i, '[', ']') is null) + { + return true; + } + } + } + + return false; } public static string? ExpandPathTokens(string? path, string scriptPath) @@ -284,7 +455,15 @@ void Flush() // ----- Script-level attributes ------------------------------------------------------ - private static void ParseScriptAttributes(string zone, ScriptManifest manifest) + // Output modes PaletteShell knows how to route. An unrecognized mode is a fail-closed error: + // it would otherwise fall through to fire-and-forget (like "None") and hide the script's real + // intent โ€” a script meant to show output would run silently and report false success. + private static readonly HashSet KnownOutputModes = new(StringComparer.OrdinalIgnoreCase) + { + "None", "Toast", "Clipboard", "Open", "File", "Markdown", "Result", "List" + }; + + private static void ParseScriptAttributes(string zone, ScriptManifest manifest, List diagnostics) { foreach (var group in FindBracketGroups(zone)) { @@ -309,22 +488,39 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) ? values[0] : "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 = ValidateTimeout(timeout); + case "ScriptTimeout" when values.Count >= 1: + if (int.TryParse(values[0], out var timeout)) + { + manifest.TimeoutMs = ValidateTimeout(timeout); + } + else + { + // A non-numeric timeout is a critical attribute we can't honor โ€” fail + // closed rather than silently run with no timeout at all. + diagnostics.Add(new ScriptParseDiagnostic( + ScriptParseSeverity.Error, + $"ScriptTimeout value {EscapeForLog(values[0])} isn't a number.")); + } break; case "ScriptOutput" when values.Count >= 1: // The mode may carry an extension hint for File mode after a colon, // e.g. 'File:csv' opens the temp file as .csv. var outputSpec = values[0]; var colon = outputSpec.IndexOf(':'); - if (colon >= 0) + var outputMode = colon >= 0 ? outputSpec[..colon].Trim() : outputSpec; + if (!KnownOutputModes.Contains(outputMode)) { - manifest.Output = outputSpec[..colon].Trim(); - manifest.FileExtension = outputSpec[(colon + 1)..].Trim(); + diagnostics.Add(new ScriptParseDiagnostic( + ScriptParseSeverity.Error, + $"ScriptOutput mode {EscapeForLog(outputMode)} isn't recognized.")); } else { - manifest.Output = outputSpec; + manifest.Output = outputMode; + if (colon >= 0) + { + manifest.FileExtension = outputSpec[(colon + 1)..].Trim(); + } } break; case "ScriptGroup" when values.Count >= 1: From b99a244479159b350cf8f415aca6f84530e6f294 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:27:27 -0400 Subject: [PATCH 18/21] small updates to README --- .gitignore | 1 - README.md | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 23fa14a..2349953 100644 --- a/.gitignore +++ b/.gitignore @@ -174,4 +174,3 @@ signing/ /.github/skills /.agents/skills /agent/skills -/PaletteShellExtension/IntegrationTestScripts/README.md diff --git a/README.md b/README.md index fa9294a..59cbb21 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ When the extension is activated, `PaletteShellExtensionPage` does the following: 4. Copies the `PaletteScriptAttributes.psm1` module, `TextCopy.dll`, and the `AGENTS.md` authoring spec next to the scripts so they're available at runtime. 5. Enumerates every `*.ps1` file in the folder (top level only) and builds the command list. -The list always begins with four built-in actions: +The list always begins with these built-in actions: - **Open scripts folder** โ€” opens your configured scripts folder in Explorer. +- **Open log folder** โ€” opens the diagnostic log folder for script runs and failures. - **Reload scripts** โ€” re-scans the folder so new or changed scripts appear. - **Create new script** โ€” opens a guided wizard that scaffolds a new `.ps1` with metadata headers. - **Browse community scripts** โ€” opens the Script Manager, or falls back to the community [PaletteShellScripts](https://github.com/paletteshell/PaletteShellScripts) repository if it isn't installed. From af11139e5bc86381bd10657c7ea211acaf5b8208 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:53:46 -0400 Subject: [PATCH 19/21] document cleanup, textcopy removal, script execution/output cleanup, compatability/failure tweaks --- AGENTS.md | 280 ++--------- CONTRIBUTING.md | 5 +- Directory.Packages.props | 3 +- .../PowerShellScriptParserTests.cs | 2 +- PaletteShellExtension.sln | 42 ++ .../Classes/AmbientRunner.cs | 79 +++ .../Classes/ClipboardHelper.cs | 61 +++ .../Classes/ScriptCompatibilityValidator.cs | 20 +- .../Classes/ScriptExecutionPlan.cs | 12 + .../Classes/ScriptOutputHandler.cs | 2 +- .../Classes/ScriptRunDispatcher.cs | 9 +- PaletteShellExtension/Classes/ScriptRunner.cs | 34 +- .../Commands/AmbientRunCommand.cs | 44 ++ .../Commands/CopyValueCommand.cs | 3 +- .../Commands/PwshMissingCommand.cs | 21 + .../Commands/ReloadPageCommand.cs | 5 + .../Commands/RunScriptCommand.cs | 8 +- .../Forms/ScriptParameterForm.cs | 19 +- .../IntegrationTestScripts/README.md | 77 +++ .../Test-Confirm-NoParams.ps1 | 19 + .../Test-Confirm-WithParams.ps1 | 25 + .../IntegrationTestScripts/Test-Cwd.ps1 | 24 + .../IntegrationTestScripts/Test-Elevation.ps1 | 28 ++ .../IntegrationTestScripts/Test-Env.ps1 | 26 + .../Test-FailingScript.ps1 | 27 + .../Test-Failure-ExitCode.ps1 | 21 + .../Test-Failure-Redaction.ps1 | 42 ++ .../IntegrationTestScripts/Test-Host-Pwsh.ps1 | 22 + .../Test-Host-WindowsPowerShell.ps1 | 21 + .../Test-Output-Clipboard.ps1 | 19 + .../Test-Output-File-Json.ps1 | 22 + .../Test-Output-List-Json.ps1 | 26 + .../Test-Output-List-Live.ps1 | 38 ++ .../Test-Output-List-Text.ps1 | 19 + .../Test-Output-Markdown.ps1 | 24 + .../Test-Output-None.ps1 | 19 + .../Test-Output-Open-Folder.ps1 | 19 + .../Test-Output-Open-Web.ps1 | 20 + .../Test-Output-Result.ps1 | 18 + .../Test-Output-Toast.ps1 | 21 + .../Test-Param-AllTypes.ps1 | 64 +++ .../Test-Param-AllowExpression.ps1 | 36 ++ .../Test-Parse-FailClosed.ps1 | 23 + .../Test-RequiresModule-Missing.ps1 | 21 + .../IntegrationTestScripts/Test-Timeout.ps1 | 22 + .../Test-Version-MaxTooLow.ps1 | 21 + .../Test-Version-MinTooHigh.ps1 | 21 + PaletteShellExtension/Package.appxmanifest | 2 +- .../Pages/PaletteShellExtensionPage.cs | 47 +- PaletteShellExtension/Pages/ScriptRunPage.cs | 206 -------- .../PaletteScriptAttributes.psm1 | 51 +- .../PaletteShellExtension.csproj | 35 +- .../SampleScripts/Base64-Decode.ps1 | 4 +- .../SampleScripts/Base64-Encode.ps1 | 4 +- .../SampleScripts/Clear-TempFiles.ps1 | 4 +- .../Clipboard-RemoveDuplicateLines.ps1 | 4 +- .../SampleScripts/Clipboard-SortLines.ps1 | 4 +- .../SampleScripts/Clipboard-ToCSV.ps1 | 4 +- .../SampleScripts/Clipboard-ToLowerCase.ps1 | 4 +- .../SampleScripts/Clipboard-ToUpperCase.ps1 | 4 +- .../SampleScripts/Clipboard-TrimLines.ps1 | 4 +- .../SampleScripts/Clipboard-UnixTimestamp.ps1 | 4 +- .../SampleScripts/Clipboard-UrlDecode.ps1 | 4 +- .../SampleScripts/Clipboard-UrlEncode.ps1 | 4 +- .../SampleScripts/Export-ProcessList.ps1 | 4 +- .../SampleScripts/Generate-GUID.ps1 | 4 +- .../SampleScripts/Get-PublicIP.ps1 | 4 +- .../SampleScripts/Git-Branches.ps1 | 2 +- .../SampleScripts/Json-Format.ps1 | 4 +- .../SampleScripts/Json-Minify.ps1 | 4 +- .../SampleScripts/Open-TempFolder.ps1 | 4 +- .../SampleScripts/Restart-Explorer.ps1 | 4 +- .../SampleScripts/System-Report.ps1 | 4 +- .../SampleScripts/Text-Transform.ps1 | 4 +- README.md | 467 ++---------------- docs/ExtensionReference.md | 23 + docs/PaletteShellScripts.AGENTS.md | 87 ++++ docs/PaletteShellScripts.Reference.md | 272 ++++++++++ docs/README.md | 28 ++ docs/execution-map.md | 30 ++ docs/extension-behavior-reference.md | 106 ++++ docs/parser-map.md | 26 + docs/project-structure-reference.md | 64 +++ docs/ui-map.md | 31 ++ docs/user-guide.md | 88 ++++ 85 files changed, 2104 insertions(+), 979 deletions(-) create mode 100644 PaletteShellExtension/Classes/AmbientRunner.cs create mode 100644 PaletteShellExtension/Classes/ClipboardHelper.cs create mode 100644 PaletteShellExtension/Commands/AmbientRunCommand.cs create mode 100644 PaletteShellExtension/Commands/PwshMissingCommand.cs create mode 100644 PaletteShellExtension/IntegrationTestScripts/README.md create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Confirm-NoParams.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Confirm-WithParams.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Cwd.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Elevation.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Env.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-FailingScript.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Failure-ExitCode.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Failure-Redaction.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Host-Pwsh.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Host-WindowsPowerShell.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-Clipboard.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-File-Json.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Json.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Live.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Text.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-Markdown.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-None.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Folder.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Web.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-Result.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Output-Toast.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Param-AllTypes.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Param-AllowExpression.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Parse-FailClosed.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-RequiresModule-Missing.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Timeout.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Version-MaxTooLow.ps1 create mode 100644 PaletteShellExtension/IntegrationTestScripts/Test-Version-MinTooHigh.ps1 delete mode 100644 PaletteShellExtension/Pages/ScriptRunPage.cs create mode 100644 docs/ExtensionReference.md create mode 100644 docs/PaletteShellScripts.AGENTS.md create mode 100644 docs/PaletteShellScripts.Reference.md create mode 100644 docs/README.md create mode 100644 docs/execution-map.md create mode 100644 docs/extension-behavior-reference.md create mode 100644 docs/parser-map.md create mode 100644 docs/project-structure-reference.md create mode 100644 docs/ui-map.md create mode 100644 docs/user-guide.md diff --git a/AGENTS.md b/AGENTS.md index b331d76..f0934f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,256 +1,56 @@ -# Authoring PaletteShell scripts โ€” guide for AI agents +# PaletteShellExtension Agent Guide -This file is the **contract** for writing `.ps1` scripts that PaletteShell will load and run. -Follow it exactly. It is written for AI coding agents (and humans) who scaffold or edit scripts -in a PaletteShell scripts folder. +This repository is the C# Windows Command Palette extension for PaletteShell. +Keep this file short: it is loaded by coding agents for ordinary repo work. -> A copy of this file ships into every user's `Documents\PaletteShellScripts` folder next to -> `PaletteScriptAttributes.psm1`. If you are an agent working in that folder, treat this as the -> authoritative spec โ€” do not guess at attribute names or output modes. +## Route by Task -## What PaletteShell does with a script +- Changing the extension itself: read `CONTRIBUTING.md`, then inspect the relevant C# files. +- Authoring or updating PaletteShell `.ps1` scripts: read `docs/PaletteShellScripts.AGENTS.md`. +- Updating script-facing behavior, attributes, output modes, or samples: update `README.md` and `docs/PaletteShellScripts.AGENTS.md` in the same change. +- Parser or attribute work: start with `docs/parser-map.md`. +- Script execution or output behavior: start with `docs/execution-map.md`. +- Command Palette pages, forms, or settings: start with `docs/ui-map.md`. -PaletteShell reads **metadata out of each `.ps1` file without executing it**, using a lightweight -text parser (`PowerShellScriptParser`). It then either runs the script or renders a form/page from -that metadata. Because the parser is text-based, **your script must follow the regular shape below** -โ€” metadata placed in the wrong spot is silently ignored. +## Reference Discipline -Scripts live in `Documents\PaletteShellScripts` (top level only โ€” subfolders are not scanned). -Changes are picked up when the user runs **"Reload scripts"**, not automatically. +Do not read split references such as `docs/extension-behavior-reference.md`, +`docs/project-structure-reference.md`, or `docs/PaletteShellScripts.Reference.md` unless the task +specifically needs full reference behavior. +Prefer the short router files and focused maps first, then open the bigger references only for +exact attribute, output-mode, project-structure, or extension behavior details. -## The required shape +Ignore `bin/`, `obj/`, `AppPackages/`, generated assets, and other build output unless the task is +specifically about packaging, generated artifacts, or build-output diagnostics. Do not summarize +generated output when source files or focused docs answer the question. -```powershell -using module .\PaletteScriptAttributes.psm1 # line 1 โ€” lets the [Script*] attributes resolve at runtime - -<# -.SYNOPSIS - Short title (this becomes the command's title in the palette) -.DESCRIPTION - Longer description (becomes the subtitle) -.PARAMETER MyParameter - Help text for MyParameter (becomes its form-field label / placeholder) -#> -[ScriptHost('pwsh')] # <-- all script-level attributes go ABOVE param(...) -[ScriptGroup('My Category')] -[ScriptIcon('๐ŸŽฏ')] -[ScriptOutput('Clipboard')] -[CmdletBinding()] -param( - [Parameter(Mandatory=$true)] - [string]$MyParameter -) - -# --- script body --- -$result = $MyParameter.ToUpper() -Set-ClipboardText $result -``` - -**Hard rules the parser enforces:** - -1. `using module .\PaletteScriptAttributes.psm1` should be the **first line**. It is required for the - custom `[Script*]` attributes and the helper functions to resolve when the script actually runs. -2. All `[Script*]` attributes must appear **before the `param(` keyword**. Attributes after `param(` - or inside comments are ignored. -3. Comment-based help must be a `<# ... #>` block. Only `.SYNOPSIS`, `.DESCRIPTION`, and - `.PARAMETER ` are read (`.EXAMPLE`, `.NOTES`, etc. are fine to include but ignored by the UI). -4. If there is no `.SYNOPSIS`, the title falls back to the file name. Always provide one. -5. Save the file as **UTF-8** (emoji icons and non-ASCII rely on it). - -## Script-level attributes - -Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything else is ignored. - -| Attribute | Purpose | -|-----------|---------| -| `[ScriptHost('pwsh')]` | Host to run under: `'pwsh'` (PowerShell 7, default) or `'powershell'` (Windows PowerShell 5.1) | -| `[ScriptCwd('{ScriptDir}')]` | Working directory (supports path tokens, below) | -| `[ScriptGroup('Category')]` | Group name used by tooling such as the Script Manager catalog browser | -| `[ScriptTags('foo,bar,baz')]` | Comma-delimited free-form tags used by tooling such as the Script Manager catalog browser | -| `[ScriptVersion('1.0.0')]` | Script version (SemVer recommended) โ€” lets tools detect when a newer copy is available. A script that omits it is treated as `1.0.0` (the file is not rewritten) | -| `[RequiresPaletteShellMinimum('1.2.0')]` | Minimum PaletteShell app version required. If the installed app is older, the row shows "Requires an update" instead of running the script. Defaults to `0.0.6` (the last release before this attribute existed) when omitted | -| `[RequiresPaletteShellMaximum('2.0.0')]` | Maximum PaletteShell app version this script still works on. If the installed app is newer, the row shows "Requires an update" instead of running the script. Optional โ€” use only if your script depends on behavior later removed or changed | -| `[ScriptIcon('๐Ÿš€')]` | Emoji or glyph shown on the row | -| `[ScriptOutput('None')]` | How stdout is handled (see [Output modes](#output-modes)) | -| `[ScriptTimeout(30000)]` | Timeout in **milliseconds**; also forces wait-and-capture | -| `[ScriptEnv('VAR', 'value')]` | Set an environment variable; repeat the attribute for more than one | -| `[RequiresModule('ImportExcel')]` | PowerShell module the script needs installed; repeat the attribute for more than one. Checked at run time โ€” a missing module fails the run with an `Install-Module -Name โ€ฆ -Scope CurrentUser` hint instead of the script's own cryptic error | -| `[RequiresElevation()]` | Run elevated (admin). Equivalent to `#Requires -RunAsAdministrator`. Output capture is unavailable when elevated, so elevation is only compatible with `[ScriptOutput('None')]` โ€” any other output mode makes the script an incompatible row instead of running. | -| `[ConfirmBeforeRun('message')]` | Show a yes/no dialog with `message` before running. Pair with `[RequiresElevation()]` for destructive scripts. | - -### Path tokens - -`[ScriptCwd(...)]` and `[ScriptEnv(...)]` values expand these at runtime: - -- `{ScriptDir}` โ€” the folder containing the script -- `{Home}` โ€” the user's profile folder -- `{Temp}` โ€” the system temp folder - -## Output modes - -Set with `[ScriptOutput('')]`. Default is `None`. - -| Mode | Behavior | -|------|----------| -| `None` | Run silently; a "Script completed" toast is shown. With no `[ScriptTimeout]` this is **fire-and-forget** (output is not captured). | -| `Toast` | Wait, capture stdout, show it in a Windows notification. | -| `Clipboard` | Wait, capture stdout, copy it to the clipboard. | -| `Markdown` | Wait, render stdout as formatted Markdown on its own page. | -| `Result` | Wait, show stdout as a single copyable result (Enter copies; a **Run again** command regenerates). Print just the value; good for generators (GUID, password, token). | -| `File` | Write stdout to a temp file and open it in the user's editor. Add an extension hint after a colon: `File:csv`, `File:json`, etc. Best for large/structured output. | -| `List` | Parse stdout into a searchable, pickable list โ€” turns the script into a search/pick provider (see below). | -| `Open` | Open the first non-empty stdout line as a URL, file, or folder path. | - -Anything other than `None` (or any `[ScriptTimeout]`) makes PaletteShell **wait** for the process, -up to the timeout (30s default) before killing it. So: emit results on **stdout** (`Write-Host` / -`Write-Output` are captured), keep the run under the timeout, and exit non-zero on failure. - -## Parameters โ†’ form fields - -If a script declares a `param(...)` block (and is not a `List` script), PaletteShell auto-generates -an input form. Type and validation map to UI: - -| PowerShell | Form field | -|------------|------------| -| `[string]` | text box | -| `[int]` / `[long]` | integer input | -| `[double]` / `[decimal]` / `[single]` | number input | -| `[switch]` / `[bool]` | checkbox | -| `[ValidateSet('A','B','C')]` | dropdown | -| `[ValidateRange(min,max)]` | min/max bounds on the number input | -| `[Parameter(Mandatory=$true)]` | required field | - -The field **label** comes from `[Parameter(HelpMessage='...')]`, else the `.PARAMETER` help, else the -variable name. Provide one of the first two so the form reads well. - -By default a form value is passed to the script as a **literal string**. If a parameter should accept -a PowerShell expression instead (evaluated, not quoted), mark it `[AllowExpression()]`. - -## Designing inputs โ€” what a PaletteShell script can (and can't) assume - -This is the most important design decision, and the one agents get wrong most often. **A PaletteShell -script starts cold.** Unlike a shell command or an editor extension, it has **no ambient context**: - -- **No current working directory** โ€” the palette has no concept of "where you are." `[ScriptCwd(...)]` - only sets a *fixed* or token-based directory (`{ScriptDir}`, `{Home}`, `{Temp}`), never "the folder - I'm looking at." A script cannot discover a directory the way `cd`-relative shell tools do. -- **No current file, selection, or project** โ€” there is nothing selected to operate on. -- **No prior command / no argv** beyond what you collect explicitly. - -So every piece of context a script needs must arrive through one of these channels, each with a cost: +## Project Map -| Channel | How | Friction | Best for | -|---------|-----|----------|----------| -| **Fixed / token path** | `[ScriptCwd]`, `[ScriptEnv]`, `{Home}`/`{Temp}`, or a constant in the body | None โ€” zero input | A target that is always the same (temp cleanup, a known repo, "my downloads") | -| **Clipboard** | `Get-ClipboardText` (transform, `Set-ClipboardText` back) | Low *if* the value is already copied; cumbersome if the user must go copy it first | Text/paths the user just yanked from elsewhere โ€” the core clipboard-utility pattern | -| **Form field** | a `param(...)` entry โ†’ auto form | Medium โ€” the user types/pastes each run | Occasional values, or a required value with no sensible default | -| **Live List provider** | one-parameter `[ScriptOutput('List')]`; search box *is* the input | Low โ€” type-to-refine, no form, no submit | Search/pick/filter over a space (branches, files, lookups) | +- `PaletteShellExtension/` - extension source. +- `PaletteShellExtension.Tests/` - xUnit tests. +- `PaletteShellExtension/PowerShellScriptParser.cs` - script metadata and parameter parser. +- `PaletteShellExtension/Classes/ScriptRunner.cs` - PowerShell process execution. +- `PaletteShellExtension/Classes/ScriptOutputHandler.cs` - stdout-to-output-mode handling. +- `PaletteShellExtension/Pages/PaletteShellExtensionPage.cs` - main command list, script discovery, and built-in commands. +- `PaletteShellExtension/Forms/` - generated forms for setup, script parameters, and script creation. +- `PaletteShellExtension/SampleScripts/` - bundled scripts copied to the user's scripts folder. -### The working-directory problem, concretely +## Validation -You **cannot** write a script that "runs in the current folder" โ€” there is no current folder. To act on -a directory you must **make the directory an input**: - -- A **form field** (`[string]$Path`) works but means pasting a long path on every run โ€” heavy for a - keyboard-driven launcher. -- The **clipboard** works if the user already copied the path, but forcing them to go copy it first, run - the script, and read the result is more steps than just opening a terminal. -- A **live List provider** (path as the query parameter, results refresh as you type) is usually the - best fit โ€” see `Git-Branches.ps1`, which takes a repo path as its live query. - -### Aim for the happy medium - -Match the **input cost to the payoff**. A one-keystroke utility should not demand a paste; a script that -needs rich context should earn it. Prefer, in order: - -1. **No input** โ€” operate on the clipboard or a fixed/derivable location. This is the sweet spot for a - launcher: select โ†’ done. -2. **A default that makes the field optional** โ€” e.g. default `$Path` to `{Home}` or a sensible root, so - the form can be submitted empty for the common case and overridden only when needed. -3. **A dropdown over free text** โ€” `[ValidateSet(...)]` turns "type the exact value" into a quick pick, - removing typos and typing. -4. **A single meaningful field**, well-labeled via `.PARAMETER` / `HelpMessage`. - -**Avoid** designing a script that requires several heavy inputs (two paths, a path *and* a query, โ€ฆ): the -collection friction usually exceeds what the palette is good for, and that task belongs in a terminal or a -real app. If a script only makes sense with a lot of context, that is a signal it is the wrong tool for the -palette. - -## List output โ€” static list vs. live provider - -`[ScriptOutput('List')]` runs the script and turns its stdout into a searchable list. Picking an item -copies its value; items with a `url` also get an **Open** command. The `param()` block decides the mode: - -- **No parameter** โ†’ the script runs once; the palette search box filters the results locally. -- **One parameter** โ†’ **live provider**: the palette's search text is passed as that parameter and the - list refreshes as the user types. Only the first parameter is used; the parameter form is skipped. - The script also runs once on open with an empty value โ€” handle the blank case (return a - "Type a pathโ€ฆ" prompt item rather than erroring). - -Stdout is parsed as either: - -- **Newline-delimited text** โ€” each non-empty line is an item (title = copy value). -- **A JSON array** (e.g. `... | ConvertTo-Json -AsArray -Compress`). Array of strings behaves like the - line case; array of objects maps these fields (all optional, case-insensitive): - - | Field | Purpose | - |-------|---------| - | `title` / `name` / `label` / `text` | Item title (and copy value if `value` omitted) | - | `subtitle` / `description` / `detail` | Secondary line | - | `value` / `copy` | Text copied when the item is picked | - | `url` / `link` | Adds an **Open** command that launches the URL | - | `icon` | Emoji or glyph on the item | - -```powershell -# Live provider example -<# -.PARAMETER Query - Type a search termโ€ฆ -#> -[ScriptOutput('List')] -param([string]$Query) - -@( - [pscustomobject]@{ title = "Result for $Query"; subtitle = 'picked โ†’ copied'; value = $Query } -) | ConvertTo-Json -AsArray -Compress -``` - -## Open output - -`[ScriptOutput('Open')]` runs the script and opens the first non-empty stdout line as a URL, -file, or folder path. Emit only the target on stdout. - -```powershell -[ScriptOutput('Open')] -param() - -[System.IO.Path]::GetTempPath() -``` - -## Helper functions (from the module) - -Because line 1 imports `PaletteScriptAttributes.psm1`, these are available: +Use x64 unless the task specifically targets ARM64: ```powershell -$text = Get-ClipboardText # read clipboard (cross-platform, with fallbacks) -Set-ClipboardText "Hello World" # write clipboard (cross-platform) +dotnet build PaletteShellExtension.sln -c Debug -p:Platform=x64 +dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 ``` -Most clipboard-transform scripts follow: read with `Get-ClipboardText`, guard the empty case, -transform, write back with `Set-ClipboardText`, and `Write-Host` a short status line. - -## Checklist before you finish a script +For parser, manifest, output-mode, or form behavior changes, add or update focused tests in +`PaletteShellExtension.Tests/`. -- [ ] `using module .\PaletteScriptAttributes.psm1` on line 1. -- [ ] `<# .SYNOPSIS ... #>` block with a real title and description; a `.PARAMETER` line per parameter. -- [ ] All `[Script*]` attributes are above `param(`, and only use the recognized attribute names above. -- [ ] Input cost fits the payoff: no ambient working dir/selection exists, so prefer clipboard or a fixed/token path over forcing a pasted path; give optional fields a sensible default; avoid multiple heavy inputs. -- [ ] Output mode matches how results are surfaced; results go to **stdout**. -- [ ] Destructive or admin scripts have `[ConfirmBeforeRun('...')]` (and `[RequiresElevation()]` if needed). -- [ ] Long-running scripts set a realistic `[ScriptTimeout(ms)]`. -- [ ] Saved as UTF-8; icon is a single emoji/glyph. -- [ ] Exits non-zero on failure so the palette reports it. +## Notes -See the bundled sample scripts (`Text-Transform.ps1`, `Git-Branches.ps1`, `Open-TempFolder.ps1`, -`System-Report.ps1`, `Clear-TempFiles.ps1`, โ€ฆ) for working examples of each pattern, and the community library at -. +- The extension copies a short script-authoring guide into user script folders as `AGENTS.md`. + The full reference ships beside it as `PaletteShellScripts.Reference.md`. +- Scripts are top-level `.ps1` files; subfolders are not scanned. +- Script metadata is parsed from text by `PowerShellScriptParser`; do not assume scripts are executed + just to discover metadata. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e43ae5e..5d61872 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ 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. +and [script authoring agent guide](docs/PaletteShellScripts.AGENTS.md) instead โ€” this doc is for people changing the extension's code. ## Prerequisites @@ -81,5 +81,6 @@ needed to trust the unsigned test package. - 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) + relevant section of [README.md](README.md) and, if it affects script authoring, [docs/PaletteShellScripts.AGENTS.md](docs/PaletteShellScripts.AGENTS.md) + plus [docs/PaletteShellScripts.Reference.md](docs/PaletteShellScripts.Reference.md) in the same PR โ€” they're both meant to stay authoritative. diff --git a/Directory.Packages.props b/Directory.Packages.props index 630e991..1fa9a65 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,5 @@ - - \ No newline at end of file + diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs index 04efb8b..36de9c1 100644 --- a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -85,7 +85,7 @@ The name to greet. [InlineData("[long]$P", "int")] [InlineData("[double]$P", "number")] [InlineData("[decimal]$P", "number")] - [InlineData("[switch]$P", "bool")] + [InlineData("[switch]$P", "switch")] // presence-based flag; distinct UI type from [bool] [InlineData("[bool]$P", "bool")] [InlineData("$P", "string")] // no type constraint at all public void ParameterType_MapsToUiType(string declaration, string expectedUiType) diff --git a/PaletteShellExtension.sln b/PaletteShellExtension.sln index 8088a57..366d622 100644 --- a/PaletteShellExtension.sln +++ b/PaletteShellExtension.sln @@ -7,6 +7,41 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteShellExtension", "Pa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteShellExtension.Tests", "PaletteShellExtension.Tests\PaletteShellExtension.Tests.csproj", "{B3E8D0AB-C335-4D95-8F08-E705407147AC}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Documentation", "Documentation", "{5A5C0B76-7C24-4E61-B15C-0BB1E47704E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Repository Docs", "Repository Docs", "{A8FA3938-B9C7-4D92-8DA1-A0CA51971969}" + ProjectSection(SolutionItems) = preProject + AGENTS.md = AGENTS.md + CONTRIBUTING.md = CONTRIBUTING.md + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs Indexes", "Docs Indexes", "{62FC42DA-4641-43F5-A586-7B48F1B9E65C}" + ProjectSection(SolutionItems) = preProject + docs\ExtensionReference.md = docs\ExtensionReference.md + docs\README.md = docs\README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Focus Maps", "Focus Maps", "{2DCC1291-5681-408E-97F2-65F24885FE43}" + ProjectSection(SolutionItems) = preProject + docs\execution-map.md = docs\execution-map.md + docs\parser-map.md = docs\parser-map.md + docs\ui-map.md = docs\ui-map.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extension References", "Extension References", "{74BD7327-3D27-44BB-B052-62588D2E59F8}" + ProjectSection(SolutionItems) = preProject + docs\extension-behavior-reference.md = docs\extension-behavior-reference.md + docs\project-structure-reference.md = docs\project-structure-reference.md + docs\user-guide.md = docs\user-guide.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Script Authoring", "Script Authoring", "{2EF03AE9-8F45-4029-AB0D-D2AFD808367E}" + ProjectSection(SolutionItems) = preProject + docs\PaletteShellScripts.AGENTS.md = docs\PaletteShellScripts.AGENTS.md + docs\PaletteShellScripts.Reference.md = docs\PaletteShellScripts.Reference.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -39,6 +74,13 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A8FA3938-B9C7-4D92-8DA1-A0CA51971969} = {5A5C0B76-7C24-4E61-B15C-0BB1E47704E8} + {62FC42DA-4641-43F5-A586-7B48F1B9E65C} = {5A5C0B76-7C24-4E61-B15C-0BB1E47704E8} + {2DCC1291-5681-408E-97F2-65F24885FE43} = {5A5C0B76-7C24-4E61-B15C-0BB1E47704E8} + {74BD7327-3D27-44BB-B052-62588D2E59F8} = {5A5C0B76-7C24-4E61-B15C-0BB1E47704E8} + {2EF03AE9-8F45-4029-AB0D-D2AFD808367E} = {5A5C0B76-7C24-4E61-B15C-0BB1E47704E8} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {CEDBC581-5818-4350-BC8A-A1ECE687D357} EndGlobalSection diff --git a/PaletteShellExtension/Classes/AmbientRunner.cs b/PaletteShellExtension/Classes/AmbientRunner.cs new file mode 100644 index 0000000..23f4823 --- /dev/null +++ b/PaletteShellExtension/Classes/AmbientRunner.cs @@ -0,0 +1,79 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using System; + +namespace PaletteShellExtension.Classes; + +/// +/// Runs an ambient (fire-and-forget) script and returns a toast +/// reporting the outcome. Ambient modes' payoff is external or nil (set the clipboard, open a +/// target, open output in an editor, or nothing) โ€” there's no page to render into, so the result is +/// surfaced as a toast. +/// +/// The toast dismisses the palette (the toolkit default for ), +/// so an ambient run ends the way it did before the async refactor โ€” run, toast, done โ€” rather than +/// parking on a status page the user has to dismiss. +/// +/// The run is synchronous and bounded by the plan's timeout so the toast can carry the real outcome. +/// Ambient modes are for quick scripts; anything that produces slow or rich output uses a display +/// mode (Result/Markdown/List), which stays on its own async page instead. +/// +internal static class AmbientRunner +{ + public static CommandResult RunAndToast( + ScriptExecutionPlan plan, + ScriptManifest manifest, + string scriptName, + string args = "") + { + ScriptRunner.ScriptResult? result; + try + { + result = ScriptExecutionService.RunAndWait(plan, args); + } + catch (Exception ex) + { + return Toast($"Error running {scriptName}: {ex.Message}"); + } + + if (result is null || result.TimedOut || result.ExitCode != 0) + { + return Toast(FailureMessage(result, scriptName)); + } + + // Elevated runs can't capture output; the compatibility gate keeps capturing modes off + // elevation, so this is only null for None-mode elevated runs. + var output = plan.CaptureOutput ? result.StandardOutput : null; + var outcome = ScriptRunDispatcher.Apply(manifest, output, scriptName); + + // A non-web, non-file open target: name it in the toast rather than launching it silently. + if (outcome.UnsafeOpenTarget is { } target) + { + return Toast($"Script output isn't a safe target to open: {target}"); + } + + return Toast(outcome.Status); + } + + /// Fire-and-forget toast: shows the outcome and dismisses the palette (the toolkit + /// default), so an ambient run ends the way it did before the async refactor โ€” run, toast, done. + public static CommandResult Toast(string message) + => CommandResult.ShowToast(message); + + private static string FailureMessage(ScriptRunner.ScriptResult? result, string scriptName) + { + if (result is null) + { + return $"Failed to start {scriptName}."; + } + + if (result.TimedOut) + { + return $"{scriptName} timed out."; + } + + var error = result.StandardError?.Trim(); + return string.IsNullOrEmpty(error) + ? $"{scriptName} failed with exit code {result.ExitCode}." + : $"{scriptName} failed: {error}"; + } +} diff --git a/PaletteShellExtension/Classes/ClipboardHelper.cs b/PaletteShellExtension/Classes/ClipboardHelper.cs new file mode 100644 index 0000000..cd5ae41 --- /dev/null +++ b/PaletteShellExtension/Classes/ClipboardHelper.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using Windows.ApplicationModel.DataTransfer; + +namespace PaletteShellExtension.Classes; + +/// +/// Native WinRT clipboard write for the Windows-only packaged extension. +/// +/// The WinRT clipboard APIs ( / ) +/// must be called on an STA thread. Our callers run on the threadpool (script output is dispatched +/// from an async continuation, command Invoke can land anywhere), which is MTA โ€” calling directly +/// there throws RPC_E_WRONG_THREAD and the clipboard is never set. So the write is marshalled onto +/// a dedicated STA thread here. Callers keep their own try/catch; clipboard access can fail +/// transiently and any STA-thread exception is rethrown to them. +/// +internal static class ClipboardHelper +{ + internal static void SetText(string text) + { + RunOnSta(() => + { + var data = new DataPackage(); + data.SetText(text ?? ""); + Clipboard.SetContent(data); + Clipboard.Flush(); // persist so the content survives after this process exits + }); + } + + // Run the action on a fresh STA thread and block until it finishes, surfacing any exception. + private static void RunOnSta(Action action) + { + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + action(); + return; + } + + Exception? failure = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ex; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + thread.Join(); + + if (failure is not null) + { + throw failure; + } + } +} diff --git a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs index c1e5b72..ea17f5d 100644 --- a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs +++ b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs @@ -17,6 +17,12 @@ internal enum ScriptCompatibilityKind /// interpreter (not auto/pwsh/powershell). Caught at discovery so it never runs under the /// wrong shell via a silent fallback. UnknownHost, + + /// The script's effective host is PowerShell 7 (pwsh), but pwsh.exe isn't + /// installed on this machine. Blocked at discovery so it shows a "install pwsh" warning + /// instead of a runnable row that fails on click. Note auto is never blocked โ€” it + /// falls back to Windows PowerShell. + PwshMissing, } /// @@ -32,7 +38,12 @@ internal readonly record struct ScriptCompatibility( { private static readonly ScriptCompatibility Compatible = new(ScriptCompatibilityKind.Ok); - public static ScriptCompatibility Validate(ScriptManifest? manifest) + /// Whether pwsh.exe was found on this machine. The caller probes + /// once per refresh () and passes the result in so the + /// per-script validation stays a pure check with no filesystem I/O. + /// The configured default host applied when a script declares none โ€” + /// needed to resolve the effective host for the pwsh-missing gate. + public static ScriptCompatibility Validate(ScriptManifest? manifest, bool pwshAvailable, string? defaultHost) { if (manifest is null) return Compatible; @@ -46,6 +57,13 @@ public static ScriptCompatibility Validate(ScriptManifest? manifest) if (manifest.Host is not null && ScriptRunner.ParseHost(manifest.Host) == ScriptRunner.ShellHost.Unknown) return new ScriptCompatibility(ScriptCompatibilityKind.UnknownHost, BadHost: manifest.Host); + // A script whose effective host resolves to pwsh (declared, or via the default) can't run + // if pwsh 7 isn't installed โ€” ResolveShell would throw. Block it up front with an + // actionable warning. `auto` is deliberately not caught here: it falls back to 5.1. + var effectiveHost = manifest.Host ?? defaultHost; + if (!pwshAvailable && ScriptRunner.ParseHost(effectiveHost) == ScriptRunner.ShellHost.Pwsh) + return new ScriptCompatibility(ScriptCompatibilityKind.PwshMissing); + if (ScriptElevation.IsElevatedOutputIncompatible(manifest)) return new ScriptCompatibility(ScriptCompatibilityKind.ElevationIncompatible); diff --git a/PaletteShellExtension/Classes/ScriptExecutionPlan.cs b/PaletteShellExtension/Classes/ScriptExecutionPlan.cs index cdbe496..62d5f6b 100644 --- a/PaletteShellExtension/Classes/ScriptExecutionPlan.cs +++ b/PaletteShellExtension/Classes/ScriptExecutionPlan.cs @@ -50,4 +50,16 @@ internal sealed class ScriptExecutionPlan /// True when the declared output mode needs the script's stdout (anything but None). public bool SurfacesOutput => !string.Equals(OutputMode, "None", StringComparison.OrdinalIgnoreCase); + + /// Output modes whose payoff lives *in* the palette: they navigate to a page that + /// stays open and renders the run's output. Everything else is "ambient" โ€” the effect is + /// external (open a file/URL, set the clipboard) or nil, so the run dismisses the palette and + /// reports completion through a host banner instead of parking a page. + private static readonly HashSet DisplayModes = + new(StringComparer.OrdinalIgnoreCase) { "Result", "Markdown", "List" }; + + /// True for the fire-and-forget dispositions (None/Toast/Clipboard/Open/File): the + /// run's payoff is external or nil, so the palette dismisses and completion is surfaced via a + /// host banner rather than an in-palette page. False for the display modes (Result/Markdown/List). + public bool IsAmbient => !DisplayModes.Contains(OutputMode); } diff --git a/PaletteShellExtension/Classes/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs index b20a5de..c8237d4 100644 --- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs +++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs @@ -72,7 +72,7 @@ internal static void TrySetClipboard(string text) { try { - TextCopy.ClipboardService.SetText(text ?? ""); + ClipboardHelper.SetText(text); } catch (Exception ex) { diff --git a/PaletteShellExtension/Classes/ScriptRunDispatcher.cs b/PaletteShellExtension/Classes/ScriptRunDispatcher.cs index d2af833..e2b6e47 100644 --- a/PaletteShellExtension/Classes/ScriptRunDispatcher.cs +++ b/PaletteShellExtension/Classes/ScriptRunDispatcher.cs @@ -4,8 +4,9 @@ namespace PaletteShellExtension.Classes; /// Decides what to do with a completed script run according to its declared /// [ScriptOutput(...)] mode, and performs the terminal side effect (set the clipboard, /// open the output in an editor, open a safe target). It returns a surface-agnostic -/// so both the async (a list surface) -/// and the parameter form's in-place render (a Markdown surface) share one set of decisions. +/// so the ambient fire-and-forget path (, which +/// toasts the outcome after dismissing the palette) and the parameter form's in-place render (a +/// Markdown surface) share one set of decisions. /// /// This is the async counterpart of the old synchronous output handling: it only ever runs after /// the script has finished on a background thread, never on the thread the host is blocked on for @@ -75,7 +76,9 @@ public static RunOutcome Apply(ScriptManifest manifest, string? output, string s null, null); - // "toast" (and any unrecognized value) surfaces the captured output. + // "toast" (and any unrecognized value) surfaces the captured output as the Status โ€” + // which the ambient path shows in its completion banner (a real toast), and the + // parameter form renders in place. default: var value = output?.Trim(); return string.IsNullOrEmpty(value) diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index e86cedc..637f9d9 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -201,6 +201,33 @@ public static string DescribeShell(string? host) /// Finds pwsh.exe on PATH or in the standard PowerShell 7+ install locations. private static string? FindPwsh() => FindExecutable("pwsh.exe", PwshInstallDirs()); + // pwsh 7 detection is probed once and cached for the process so the discovery gate never + // rescans the filesystem on a machine that will never have pwsh. The only things that trigger a + // fresh probe are the first script-list build after launch and the explicit "Reload scripts" + // action (via InvalidatePwshProbe) โ€” an install is picked up on the next reload, not by polling. + // ResolveShell deliberately does NOT use this cache: run-time interpreter selection stays live. + private static volatile bool _pwshProbed; + private static volatile bool _pwshInstalled; + + /// True when PowerShell 7 (pwsh.exe) is installed. Probed once and cached for the + /// process; only the first list build and an explicit reload () + /// re-scan, so a pwsh-less machine isn't hit on every rebuild. + public static bool IsPwshInstalled() + { + if (!_pwshProbed) + { + _pwshInstalled = FindPwsh() is not null; + _pwshProbed = true; + } + + return _pwshInstalled; + } + + /// Drops the cached pwsh result so the next re-probes. + /// Wired to "Reload scripts" so installing pwsh and reloading flips pwsh-pinned scripts to + /// runnable at once. + public static void InvalidatePwshProbe() => _pwshProbed = false; + /// Finds powershell.exe on PATH or in its fixed System32 location. private static string? FindWindowsPowerShell() => FindExecutable("powershell.exe", WindowsPowerShellInstallDirs()); @@ -337,9 +364,14 @@ private static string BuildModuleCheck(IReadOnlyList? requiredModules) } var list = string.Join(",", quoted); + // Write straight to stderr instead of Write-Error: the latter's rendering depends on + // $ErrorView, which is ConciseView (clean one-liner) on PowerShell 7 but NormalView on + // Windows PowerShell 5.1 โ€” there it wraps the message in CategoryInfo / FullyQualifiedErrorId + // / caret noise that DescribeFailure's 300-char trim then buries. A plain stderr line renders + // identically on both hosts. return "foreach ($__psRequired in @(" + list + ")) { " + "if (-not (Get-Module -ListAvailable -Name $__psRequired)) { " + - "Write-Error \"Missing required module '$__psRequired'. Install it with: Install-Module -Name $__psRequired -Scope CurrentUser\"; " + + "[Console]::Error.WriteLine(\"Missing required module '$__psRequired'. Install it with: Install-Module -Name $__psRequired -Scope CurrentUser\"); " + "exit 1 } }; "; } diff --git a/PaletteShellExtension/Commands/AmbientRunCommand.cs b/PaletteShellExtension/Commands/AmbientRunCommand.cs new file mode 100644 index 0000000..f604a56 --- /dev/null +++ b/PaletteShellExtension/Commands/AmbientRunCommand.cs @@ -0,0 +1,44 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using System.IO; + +namespace PaletteShellExtension.Commands; + +/// +/// Runs a no-parameter ambient (fire-and-forget) script: any waited output mode whose payoff is +/// external or nil โ€” Toast, Clipboard, Open, File, or None with a declared timeout. It runs the +/// script (bounded by the plan timeout), performs the declared side effect, and returns a +/// that dismisses the palette with the outcome โ€” see +/// for why the run is synchronous. +/// +/// Display modes (Result/Markdown/List) keep the palette open on their own pages instead; the pure +/// None + no-timeout case still uses (nothing to wait for). +/// +internal sealed partial class AmbientRunCommand(string path, ScriptManifest manifest, ScriptExecutionPlan plan) + : InvokableCommand +{ + public override string Name => $"Run {Path.GetFileNameWithoutExtension(path)}"; + public override IconInfo Icon => new(manifest.IconGlyph ?? ""); // Play; or the script's own glyph + + public override CommandResult Invoke() + { + // Destructive scripts gate behind a confirmation dialog; only the dialog's primary command + // actually runs the script. + if (!string.IsNullOrWhiteSpace(manifest.ConfirmMessage)) + { + var scriptName = Path.GetFileNameWithoutExtension(path); + return CommandResult.Confirm(new ConfirmationArgs + { + Title = $"Run {scriptName}?", + Description = manifest.ConfirmMessage, + PrimaryCommand = new CallbackCommand($"Run {scriptName}", RunNow), + IsPrimaryCommandCritical = true, + }); + } + + return RunNow(); + } + + private CommandResult RunNow() + => AmbientRunner.RunAndToast(plan, manifest, Path.GetFileNameWithoutExtension(path)); +} diff --git a/PaletteShellExtension/Commands/CopyValueCommand.cs b/PaletteShellExtension/Commands/CopyValueCommand.cs index fd4d12f..d36873d 100644 --- a/PaletteShellExtension/Commands/CopyValueCommand.cs +++ b/PaletteShellExtension/Commands/CopyValueCommand.cs @@ -1,5 +1,6 @@ using Microsoft.CommandPalette.Extensions.Toolkit; using System; +using ClipboardHelper = PaletteShellExtension.Classes.ClipboardHelper; namespace PaletteShellExtension.Commands; @@ -16,7 +17,7 @@ public override CommandResult Invoke() { try { - TextCopy.ClipboardService.SetText(text ?? ""); + ClipboardHelper.SetText(text); } catch (Exception) { diff --git a/PaletteShellExtension/Commands/PwshMissingCommand.cs b/PaletteShellExtension/Commands/PwshMissingCommand.cs new file mode 100644 index 0000000..a1e9150 --- /dev/null +++ b/PaletteShellExtension/Commands/PwshMissingCommand.cs @@ -0,0 +1,21 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PaletteShellExtension.Commands; + +/// +/// Stands in for a script's normal command when its effective host is PowerShell 7 (pwsh) +/// but pwsh.exe isn't installed on this machine. Selecting the row explains that pwsh 7 is +/// required and where to get it, instead of silently failing when the script is clicked. +/// +internal sealed partial class PwshMissingCommand : InvokableCommand +{ + public override string Name => "PowerShell 7 not installed"; + public override IconInfo Icon => new(""); // Warning + + // A toast truncated the message; show the full reason in a dialog the user can read. + public override CommandResult Invoke() => + WarningDialog.Show("PowerShell 7 not installed", + "This script requires PowerShell 7 (pwsh.exe), which isn't installed on this machine. " + + "Install it from https://aka.ms/powershell, or change the script host to 'auto' to run " + + "under Windows PowerShell instead."); +} diff --git a/PaletteShellExtension/Commands/ReloadPageCommand.cs b/PaletteShellExtension/Commands/ReloadPageCommand.cs index 6c97c4a..cc8472e 100644 --- a/PaletteShellExtension/Commands/ReloadPageCommand.cs +++ b/PaletteShellExtension/Commands/ReloadPageCommand.cs @@ -1,4 +1,5 @@ using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; namespace PaletteShellExtension.Commands; @@ -8,6 +9,10 @@ internal sealed partial class ReloadPageCommand(PaletteShellExtensionPage page) public override IconInfo Icon => new("๎œฌ"); // Refresh public override CommandResult Invoke() { + // Explicit reload is the one action that forces a fresh pwsh probe: a user who just + // installed pwsh 7 and hits Reload sees pwsh-pinned scripts go runnable immediately, + // instead of waiting out the negative re-probe interval. + ScriptRunner.InvalidatePwshProbe(); var count = page.RefreshFiles(); var noun = count == 1 ? "script" : "scripts"; return CommandResult.ShowToast(new ToastArgs diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index 2f8f7d1..8e9165d 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -8,9 +8,9 @@ namespace PaletteShellExtension.Commands; /// /// Runs a no-parameter script fire-and-forget: it has nothing to wait for or surface (output /// mode None with no declared timeout), so it launches the script and reports completion -/// without ever blocking the host's COM call. Every waited mode (Toast/Clipboard/Open/File, or -/// None with a declared timeout) is routed to instead, which -/// runs asynchronously so the palette can't freeze on a slow script. +/// without ever blocking the host's COM call. Every waited ambient mode (Toast/Clipboard/Open/File, +/// or None with a declared timeout) is routed to instead, which +/// dismisses the palette and runs asynchronously so a slow script can't freeze it. /// internal sealed partial class RunScriptCommand(string path, ScriptManifest? manifest) : InvokableCommand { @@ -46,7 +46,7 @@ private CommandResult RunNow() // synchronous wait would only block the host for nothing. var started = ScriptExecutionService.RunFireAndForget(plan); return started - ? CommandResult.ShowToast("Script completed") + ? AmbientRunner.Toast("Script completed") : ScriptFailurePresenter.ToCommandResult(path, plan.Host, "", null); } } diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index 36c9f24..ac58774 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -73,9 +73,14 @@ public override CommandResult SubmitForm(string inputs, string data) // a slow script no longer freezes the form while the host is blocked on the submit COM // call. Elevated runs can't capture output, so they launch fire-and-forget and report // completion immediately. + // Ambient (fire-and-forget) modes dismiss the form and toast their outcome; display + // modes (Markdown/Result) render their result in place. Elevated runs can't capture + // output, so they launch fire-and-forget regardless. Func run = _plan.RequiresAdmin ? () => ExecuteElevated(argsLine) - : () => StartAsyncRun(argsLine); + : _plan.IsAmbient + ? () => StartAmbientRun(argsLine) + : () => StartAsyncRun(argsLine); // Destructive scripts gate behind a confirmation dialog; only the dialog's // primary command runs the script (with the values already collected here). @@ -99,6 +104,16 @@ public override CommandResult SubmitForm(string inputs, string data) } } + /// Runs an ambient (fire-and-forget) script and dismisses the form with a toast of the + /// outcome, performing the declared side effect (clipboard/open/file) โ€” so the palette closes + /// rather than parking on a result the user didn't need to see. The run is synchronous (see + /// ); shared with the no-parameter . + private CommandResult StartAmbientRun(string argsLine) + { + var scriptName = System.IO.Path.GetFileNameWithoutExtension(_scriptPath); + return AmbientRunner.RunAndToast(_plan, _manifest, scriptName, argsLine); + } + /// Runs the script on a background thread so the host's submit COM call returns at /// once, showing a "Runningโ€ฆ" spinner meanwhile and rendering the result in place when it /// finishes. On success the declared output effect (clipboard/open/file) is performed and a @@ -165,7 +180,7 @@ private CommandResult ExecuteElevated(string argsLine) { var started = ScriptExecutionService.RunFireAndForget(_plan, argsLine); return started - ? CommandResult.ShowToast("Script completed") + ? AmbientRunner.Toast("Script completed") : ScriptFailurePresenter.ToCommandResult(_scriptPath, _plan.Host, argsLine, null); } diff --git a/PaletteShellExtension/IntegrationTestScripts/README.md b/PaletteShellExtension/IntegrationTestScripts/README.md new file mode 100644 index 0000000..2649341 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/README.md @@ -0,0 +1,77 @@ +# PaletteShell Integration Test Scripts + +A manual, feature-by-feature regression suite. Each script exercises **one** piece of +PaletteShell functionality and its `.DESCRIPTION` states the **EXPECT**ed behavior when run +through Command Palette. Run the suite before a release to confirm every surface still works +end-to-end (parse โ†’ form โ†’ execute โ†’ route output). + +These are **not** automated unit tests โ€” the parser has those in +`PaletteShellExtension.Tests/PowerShellScriptParserTests.cs`. These verify the real runtime +path through the palette that unit tests can't reach (UAC, editors, clipboard, live List +providers, timeouts, etc.). + +## How to run + +1. Copy every `Test-*.ps1` in this folder into your PaletteShell **scripts directory** โ€” the + same folder where `PaletteScriptAttributes.psm1` is deployed (the scripts reference it via + `using module .\PaletteScriptAttributes.psm1`, and the `[Script*]` attributes only resolve + at runtime when that module sits beside them). Copy `PaletteScriptAttributes.psm1` in too if + it isn't already there. +2. Open Command Palette โ†’ the scripts appear under the **Integration Tests** group. +3. For each, run it and compare the actual behavior against the **EXPECT** line in its + description. They are safe and non-destructive (the only writes are marker files/clipboard). + +## Coverage matrix + +| Feature | Script | Expected | +| --- | --- | --- | +| `ScriptOutput('None')` | `Test-Output-None.ps1` | Runs, "Script completed", stdout discarded | +| `ScriptOutput('Toast')` | `Test-Output-Toast.ps1` | stdout surfaced as toast/result | +| `ScriptOutput('Clipboard')` | `Test-Output-Clipboard.ps1` | stdout copied to clipboard | +| `ScriptOutput('Result')` | `Test-Output-Result.ps1` | Single copyable result; "Run again" regenerates | +| `ScriptOutput('Markdown')` | `Test-Output-Markdown.ps1` | stdout rendered as Markdown | +| `ScriptOutput('File:json')` | `Test-Output-File-Json.ps1` | Temp file opened in editor as `.json` | +| `ScriptOutput('List')` text | `Test-Output-List-Text.ps1` | Newline lines โ†’ searchable items | +| `ScriptOutput('List')` JSON | `Test-Output-List-Json.ps1` | JSON objects โ†’ items w/ title/subtitle/value | +| `List` live provider | `Test-Output-List-Live.ps1` | Query param = live search text; list refreshes as you type | +| `ScriptOutput('Open')` web | `Test-Output-Open-Web.ps1` | https URL opened, no prompt (safe target) | +| `ScriptOutput('Open')` folder | `Test-Output-Open-Folder.ps1` | Folder opened in Explorer, no prompt | +| Param types (string/int/number/bool/switch/enum), Mandatory, HelpMessage, ValidateRange, ValidateSet, defaults | `Test-Param-AllTypes.ps1` | Correct control per type; echoed values match input | +| `[AllowExpression()]` | `Test-Param-AllowExpression.ps1` | Expr evaluated; literal string quoted as-is | +| `ConfirmBeforeRun` (no params) | `Test-Confirm-NoParams.ps1` | Confirm dialog before run; cancel = no run | +| `ConfirmBeforeRun` (+ params) | `Test-Confirm-WithParams.ps1` | Confirm appears AFTER form submit | +| `RequiresElevation` | `Test-Elevation.ps1` | UAC prompt; elevated run (marker file in %TEMP%) | +| `ScriptTimeout` enforcement | `Test-Timeout.ps1` | Killed at ~2s; surfaced as timeout failure | +| Non-zero exit / stderr | `Test-Failure-ExitCode.ps1` | Surfaced as failure with exit code + stderr | +| Secret arg redaction | `Test-Failure-Redaction.ps1` | Password/Token/ApiKey values masked `***` in report; User in clear | +| `ScriptEnv` | `Test-Env.ps1` | Declared env vars present with declared values | +| `ScriptCwd` + `{Temp}` token | `Test-Cwd.ps1` | Working dir = %TEMP% | +| `ScriptHost('powershell')` | `Test-Host-WindowsPowerShell.ps1` | Runs under powershell.exe (5.x / Desktop) | +| `ScriptHost('pwsh')` | `Test-Host-Pwsh.ps1` | Runs under pwsh.exe (7.x / Core), or clear "pwsh required" error | +| `RequiresModule` (missing) | `Test-RequiresModule-Missing.ps1` | Preflight fails with Install-Module hint; body never runs | +| `RequiresPaletteShellMinimum` | `Test-Version-MinTooHigh.ps1` | Hidden/explanatory row; not runnable | +| `RequiresPaletteShellMaximum` | `Test-Version-MaxTooLow.ps1` | Hidden/explanatory row; not runnable | +| Fail-closed parsing | `Test-Parse-FailClosed.ps1` | Disabled "repair" row (bad ScriptTimeout); not runnable | + +## Also visible on every script + +`ScriptGroup('Integration Tests')`, `ScriptTags(...)`, `ScriptVersion`, and `ScriptIcon` are +set on all scripts โ€” confirm the group, per-script emoji icon, and tags render correctly in the +palette while you go through the list. + +## Keeping this suite current + +**When you change a feature, update the matching script here in the same change** (and this +matrix). Specifically: + +- **New `[Script*]` attribute or param feature** โ†’ add a `Test-*.ps1` covering it + a matrix row. +- **New/renamed `ScriptOutput` mode** โ†’ add/rename the `Test-Output-*.ps1` and update + `KnownOutputModes` expectations. (The parser's allow-list must include every mode the + dispatcher handles โ€” currently `None, Toast, Clipboard, File, Markdown, Result, List, Open`.) +- **Changed redaction rules** (`ScriptFailureReport.RedactArgs`) โ†’ update + `Test-Failure-Redaction.ps1` param names. +- **Changed path tokens** (`ExpandPathTokens`: `{ScriptDir} {Home} {Temp}`) โ†’ update `Test-Cwd.ps1`. +- **Changed timeout bounds** (`MinTimeoutMs`/`MaxTimeoutMs`) โ†’ keep `Test-Timeout.ps1` within them. + +Naming: `Test--.ps1`, group `Integration Tests`, tag with `integration`, and always +put the pass/fail criterion on an **EXPECT** line in `.DESCRIPTION`. diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Confirm-NoParams.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Confirm-NoParams.ps1 new file mode 100644 index 0000000..a0256f1 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Confirm-NoParams.ps1 @@ -0,0 +1,19 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Confirm: before run (no params) +.DESCRIPTION + EXPECT: selecting this script shows a confirmation dialog with the custom message + below BEFORE anything runs. Cancel => nothing runs. Confirm => the marker is written. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,confirm')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โš ๏ธ')] +[ScriptOutput('Toast')] +[ConfirmBeforeRun('Integration test: confirm to proceed (nothing destructive happens).')] +param() + +Write-Output "Confirmed and ran at $(Get-Date -Format o)" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Confirm-WithParams.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Confirm-WithParams.ps1 new file mode 100644 index 0000000..97c6b53 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Confirm-WithParams.ps1 @@ -0,0 +1,25 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Confirm: after form submit +.DESCRIPTION + Because this script has a parameter, the confirmation prompt must appear AFTER you + submit the form (not before it). EXPECT: fill the form, submit, THEN see the confirm + dialog. Cancel => nothing runs. +.PARAMETER Label + Any text; echoed back on confirm. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,confirm')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โš ๏ธ')] +[ScriptOutput('Toast')] +[ConfirmBeforeRun('Integration test: confirm after submitting the form.')] +param( + [Parameter(HelpMessage="Any label to echo")] + [string]$Label = "confirmed" +) + +Write-Output "Ran with Label='$Label'" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Cwd.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Cwd.ps1 new file mode 100644 index 0000000..90f41f5 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Cwd.ps1 @@ -0,0 +1,24 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: ScriptCwd (token) +.DESCRIPTION + Sets [ScriptCwd('{Temp}')] using a path token. EXPECT: the process working directory is + the user's TEMP folder โ€” the reported PWD below matches %TEMP%. + Supported tokens: {ScriptDir}, {Home}, {Temp}. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,cwd')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ“')] +[ScriptOutput('Markdown')] +[ScriptCwd('{Temp}')] +[CmdletBinding()] +param() + +Write-Host "# ScriptCwd Test" +Write-Host "" +Write-Host "- Working directory (PWD): ``$($PWD.Path)``" +Write-Host "- Expected (\$env:TEMP): ``$env:TEMP``" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Elevation.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Elevation.ps1 new file mode 100644 index 0000000..9cf40ee --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Elevation.ps1 @@ -0,0 +1,28 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Elevation: RequiresElevation +.DESCRIPTION + EXPECT: running this triggers a UAC elevation prompt. Because elevated runs cannot have + their stdout captured, the palette reports completion without surfacing output. To + confirm it actually elevated, this writes a marker file to TEMP and reports admin state + there (open %TEMP%\PaletteShell-IT-Elevation.txt). +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,elevation')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ›ก๏ธ')] +[ScriptOutput('None')] +[RequiresElevation()] +[CmdletBinding()] +param() + +$id = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object System.Security.Principal.WindowsPrincipal($id) +$isAdmin = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + +$out = Join-Path $env:TEMP 'PaletteShell-IT-Elevation.txt' +"IsAdmin=$isAdmin User=$($id.Name) At=$(Get-Date -Format o)" | Set-Content -LiteralPath $out +Write-Host "IsAdmin=$isAdmin (see $out)" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Env.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Env.ps1 new file mode 100644 index 0000000..58ecb03 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Env.ps1 @@ -0,0 +1,26 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: ScriptEnv +.DESCRIPTION + Declares two [ScriptEnv] variables. EXPECT: both are present in the process + environment and echoed below with their declared values. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,env')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐ŸŒฑ')] +[ScriptOutput('Markdown')] +[ScriptEnv('PS_IT_ONE', 'first-value')] +[ScriptEnv('PS_IT_TWO', 'second-value')] +[CmdletBinding()] +param() + +Write-Host "# ScriptEnv Test" +Write-Host "" +Write-Host "| Variable | Expected | Actual |" +Write-Host "| --- | --- | --- |" +Write-Host "| PS_IT_ONE | first-value | ``$env:PS_IT_ONE`` |" +Write-Host "| PS_IT_TWO | second-value | ``$env:PS_IT_TWO`` |" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-FailingScript.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-FailingScript.ps1 new file mode 100644 index 0000000..197c243 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-FailingScript.ps1 @@ -0,0 +1,27 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + Test Failing Script +.DESCRIPTION + Deliberately fails with a non-zero exit code, multi-line stderr, and some + stdout โ€” for verifying the failure dialog, the "View details" report, and + the stderr line in the log. +#> +[ScriptGroup('Testing')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ’ฅ')] +[ScriptOutput('Toast')] +param() + +# Some stdout so the report's stdout section has content. +Write-Host 'Doing some pretend work...' +Write-Host 'Step 1 of 3 complete.' + +# Multi-line stderr: exceeds nothing, but proves newline collapsing in the log +# line and full fidelity in the failure report. +Write-Error 'Something went wrong: could not frobnicate the widget.' +Write-Error 'Inner detail: widget service returned HTTP 503 (Service Unavailable).' +Write-Error "Hint: this is a test failure from Test-FailingScript.ps1 โ€” everything is fine." + +exit 1 diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Failure-ExitCode.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Failure-ExitCode.ps1 new file mode 100644 index 0000000..f2ab5dc --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Failure-ExitCode.ps1 @@ -0,0 +1,21 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Failure: non-zero exit +.DESCRIPTION + Writes to stderr and exits 1. EXPECT: the run is surfaced as a FAILURE (not success), + with a failure report showing the exit code and captured stderr. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,failure')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โŒ')] +[ScriptOutput('Toast')] +[CmdletBinding()] +param() + +[Console]::Error.WriteLine("Simulated failure on stderr.") +Write-Host "Some stdout before failing." +exit 1 diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Failure-Redaction.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Failure-Redaction.ps1 new file mode 100644 index 0000000..67b7526 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Failure-Redaction.ps1 @@ -0,0 +1,42 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Failure: secret redaction +.DESCRIPTION + Takes secret-looking params (Password/Token/ApiKey) and then fails (exit 1) so the + failure report includes the Args line. EXPECT: in the failure report the secret VALUES + are masked as '***' while the switch names remain โ€” e.g. -Token '***'. The non-secret + -User value must NOT be masked. +.PARAMETER User + A non-secret value (should appear in the clear). +.PARAMETER Password + Secret โ€” value must be redacted in the failure report. +.PARAMETER Token + Secret โ€” value must be redacted. +.PARAMETER ApiKey + Secret โ€” value must be redacted. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,failure,security')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ”’')] +[ScriptOutput('Toast')] +[CmdletBinding()] +param( + [Parameter()] + [string]$User = "alice", + + [Parameter()] + [string]$Password = "hunter2", + + [Parameter()] + [string]$Token = "tok_live_should_be_hidden", + + [Parameter()] + [string]$ApiKey = "ak_should_be_hidden" +) + +Write-Host "Deliberately failing so the redacted Args line is shown." +exit 1 diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Host-Pwsh.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Host-Pwsh.ps1 new file mode 100644 index 0000000..8e60142 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Host-Pwsh.ps1 @@ -0,0 +1,22 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: ScriptHost pwsh +.DESCRIPTION + Forces the PowerShell 7 host. EXPECT: runs under pwsh.exe and reports PSVersion 7.x + with PSEdition 'Core'. If pwsh is not installed, EXPECT a clear "PowerShell 7 is + required but not installed" error (not a silent fallback). +#> +[ScriptHost('pwsh')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,host')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โšก')] +[ScriptOutput('Markdown')] +param() + +Write-Host "# Host: PowerShell 7 (pwsh)" +Write-Host "" +Write-Host "- PSVersion: ``$($PSVersionTable.PSVersion)``" +Write-Host "- PSEdition: ``$($PSVersionTable.PSEdition)`` (expected: Core)" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Host-WindowsPowerShell.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Host-WindowsPowerShell.ps1 new file mode 100644 index 0000000..e292b3f --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Host-WindowsPowerShell.ps1 @@ -0,0 +1,21 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: ScriptHost powershell +.DESCRIPTION + Forces the Windows PowerShell host. EXPECT: runs under powershell.exe and reports + PSVersion 5.x with PSEdition 'Desktop'. Compare with Test-Host-Pwsh (7.x / 'Core'). +#> +[ScriptHost('powershell')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,host')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐ŸชŸ')] +[ScriptOutput('Markdown')] +param() + +Write-Host "# Host: Windows PowerShell" +Write-Host "" +Write-Host "- PSVersion: ``$($PSVersionTable.PSVersion)``" +Write-Host "- PSEdition: ``$($PSVersionTable.PSEdition)`` (expected: Desktop)" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-Clipboard.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Clipboard.ps1 new file mode 100644 index 0000000..42d921e --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Clipboard.ps1 @@ -0,0 +1,19 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: Clipboard +.DESCRIPTION + EXPECT: full stdout is placed on the clipboard and palette reports "Copied to + clipboard". Paste anywhere to verify the marker line below is on the clipboard. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ“‹')] +[ScriptOutput('Clipboard')] +[CmdletBinding()] +param() + +Write-Output "CLIPBOARD-TEST marker $(New-Guid)" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-File-Json.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-File-Json.ps1 new file mode 100644 index 0000000..c64afc2 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-File-Json.ps1 @@ -0,0 +1,22 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: File (json hint) +.DESCRIPTION + EXPECT: stdout written to a temp file and opened in the editor. The 'File:json' + extension hint should open it as a .json file (JSON syntax highlighting). +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ—‚๏ธ')] +[ScriptOutput('File:json')] +param() + +[pscustomobject]@{ + test = 'File output with json extension hint' + timestamp = (Get-Date -Format o) + nested = @{ ok = $true; items = @(1, 2, 3) } +} | ConvertTo-Json -Depth 5 diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Json.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Json.ps1 new file mode 100644 index 0000000..a6a2c0e --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Json.ps1 @@ -0,0 +1,26 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: List (JSON objects) +.DESCRIPTION + EXPECT: a JSON array of objects parsed into list items, each showing a title and + subtitle. Picking an item copies its 'value' (distinct from the title). +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output,list')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ—ƒ๏ธ')] +[ScriptOutput('List')] +[CmdletBinding()] +param() + +$items = 1..3 | ForEach-Object { + [pscustomobject]@{ + title = "Item $_" + subtitle = "Subtitle for item $_ โ€” picking copies value$_" + value = "copied-value-$_" + } +} +$items | ConvertTo-Json -Depth 4 -Compress -AsArray | Write-Output diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Live.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Live.ps1 new file mode 100644 index 0000000..c38baab --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Live.ps1 @@ -0,0 +1,38 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: List (live provider) +.DESCRIPTION + EXPECT: used as a live provider โ€” the single string parameter receives whatever you + type in the search box, and the list refreshes as you type. Type text and confirm the + echoed items update live. +.PARAMETER Query + Live search text from the palette search box. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output,list')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ”Ž')] +[ScriptOutput('List')] +[CmdletBinding()] +param( + [Parameter()] + [string]$Query +) + +if ([string]::IsNullOrWhiteSpace($Query)) { + @([pscustomobject]@{ title = 'Type to searchโ€ฆ'; subtitle = 'Items echo your query live'; value = '' }) | + ConvertTo-Json -Depth 4 -Compress -AsArray | Write-Output + return +} + +$items = 1..3 | ForEach-Object { + [pscustomobject]@{ + title = "$Query โ€” result $_" + subtitle = "Echoes the live query '$Query'" + value = "$Query-$_" + } +} +$items | ConvertTo-Json -Depth 4 -Compress -AsArray | Write-Output diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Text.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Text.ps1 new file mode 100644 index 0000000..a32ef85 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-List-Text.ps1 @@ -0,0 +1,19 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: List (newline text) +.DESCRIPTION + EXPECT: newline-delimited stdout parsed into a searchable list โ€” one selectable + item per line. Picking an item copies its text. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output,list')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ“ƒ')] +[ScriptOutput('List')] +[CmdletBinding()] +param() + +1..5 | ForEach-Object { "List item #$_" } diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-Markdown.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Markdown.ps1 new file mode 100644 index 0000000..14d5b4a --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Markdown.ps1 @@ -0,0 +1,24 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: Markdown +.DESCRIPTION + EXPECT: stdout rendered as Markdown (heading + table below appear formatted, not raw). +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ“')] +[ScriptOutput('Markdown')] +param() + +Write-Host "# Markdown Output Test" +Write-Host "" +Write-Host "If you can read a **bold** word and the table below is rendered, Markdown mode works." +Write-Host "" +Write-Host "| Key | Value |" +Write-Host "| --- | --- |" +Write-Host "| Time | $(Get-Date -Format o) |" +Write-Host "| PID | $PID |" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-None.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-None.ps1 new file mode 100644 index 0000000..1a20358 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-None.ps1 @@ -0,0 +1,19 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: None +.DESCRIPTION + Fire-and-forget run. EXPECT: script runs, palette reports "Script completed", + and no output is surfaced (stdout is discarded). No clipboard/file/editor side effect. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ”‡')] +[ScriptOutput('None')] +[CmdletBinding()] +param() + +Write-Host "This line is written to stdout but should NOT be surfaced by None mode." diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Folder.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Folder.ps1 new file mode 100644 index 0000000..1bf76c1 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Folder.ps1 @@ -0,0 +1,19 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: Open (folder, safe) +.DESCRIPTION + EXPECT: the first stdout line is an existing folder path, treated as SAFE and opened + in File Explorer without a prompt. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output,open')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ“‚')] +[ScriptOutput('Open')] +[CmdletBinding()] +param() + +[System.IO.Path]::GetTempPath() diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Web.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Web.ps1 new file mode 100644 index 0000000..45b30f8 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Open-Web.ps1 @@ -0,0 +1,20 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: Open (web, safe) +.DESCRIPTION + EXPECT: the first stdout line is an https URL, treated as a SAFE target and opened in + the default browser WITHOUT a confirmation prompt. + NOTE: requires 'Open' in KnownOutputModes (parser). Repair row => allow-list missing 'Open'. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output,open')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐ŸŒ')] +[ScriptOutput('Open')] +[CmdletBinding()] +param() + +Write-Output "https://example.com/" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-Result.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Result.ps1 new file mode 100644 index 0000000..56373b4 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Result.ps1 @@ -0,0 +1,18 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: Result +.DESCRIPTION + EXPECT: stdout shown as a single copyable result (Enter copies; "Run again" + regenerates a new value). Re-running must produce a different GUID. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐ŸŽฏ')] +[ScriptOutput('Result')] +param() + +[System.Guid]::NewGuid().ToString() diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Output-Toast.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Toast.ps1 new file mode 100644 index 0000000..5638ae8 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Output-Toast.ps1 @@ -0,0 +1,21 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Output: Toast +.DESCRIPTION + EXPECT: the trimmed stdout is surfaced as the result/toast text and offered as a + copy value. Confirms the default "surface captured output" path. + NOTE: requires 'Toast' in KnownOutputModes (parser) โ€” if it shows as a repair row, + the parser's output-mode allow-list is missing 'Toast'. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,output')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ””')] +[ScriptOutput('Toast')] +[CmdletBinding()] +param() + +Write-Output "Toast OK @ $(Get-Date -Format o)" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Param-AllTypes.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Param-AllTypes.ps1 new file mode 100644 index 0000000..7e190b0 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Param-AllTypes.ps1 @@ -0,0 +1,64 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Params: all types +.DESCRIPTION + Exercises every parameter UI type the parser maps: string, int (ValidateRange), + number/double, bool, switch, and enum (ValidateSet). Also covers Mandatory, a + comment-help .PARAMETER label, an inline HelpMessage, and default values. + EXPECT: the form renders the right control per type (dropdown for enum, toggle for + switch/bool, numeric fields for int/number), enforces the range and the required + field, and the Markdown table below echoes exactly what you submitted. +.PARAMETER Name + A plain string (label comes from this comment-help line). +.PARAMETER Count + Integer constrained to 1..10 via ValidateRange. +.PARAMETER Ratio + A double/number value. +.PARAMETER Mode + Enum rendered as a dropdown (ValidateSet). +.PARAMETER Enabled + A [bool] โ€” passed to PowerShell as $true/$false. +.PARAMETER Verbose2 + A [switch] โ€” present when on, omitted when off. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,params')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿงฉ')] +[ScriptOutput('Markdown')] +[CmdletBinding()] +param( + [Parameter(Mandatory=$true, HelpMessage="Required string โ€” try leaving it blank")] + [string]$Name = "world", + + [Parameter(HelpMessage="Integer 1..10")] + [ValidateRange(1, 10)] + [int]$Count = 3, + + [Parameter()] + [double]$Ratio = 1.5, + + [Parameter(HelpMessage="Pick one")] + [ValidateSet("Alpha", "Beta", "Gamma")] + [string]$Mode = "Beta", + + [Parameter()] + [bool]$Enabled = $true, + + [Parameter()] + [switch]$Verbose2 +) + +Write-Host "# Parameter Binding Test" +Write-Host "" +Write-Host "| Parameter | Type | Value |" +Write-Host "| --- | --- | --- |" +Write-Host "| Name | string | ``$Name`` |" +Write-Host "| Count | int | ``$Count`` |" +Write-Host "| Ratio | double | ``$Ratio`` |" +Write-Host "| Mode | enum | ``$Mode`` |" +Write-Host "| Enabled | bool | ``$Enabled`` |" +Write-Host "| Verbose2 | switch | ``$($Verbose2.IsPresent)`` |" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Param-AllowExpression.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Param-AllowExpression.ps1 new file mode 100644 index 0000000..12dc80e --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Param-AllowExpression.ps1 @@ -0,0 +1,36 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Params: AllowExpression +.DESCRIPTION + The Expr parameter is marked [AllowExpression], so the form value is injected verbatim + for PowerShell to evaluate instead of being quoted as a literal string. + EXPECT: enter e.g. (2 + 3) * 4 and the result is 20. A normal [string] param would show + the literal text "(2 + 3) * 4" instead. + Compare with Literal, which is quoted and echoed as-is. +.PARAMETER Expr + A PowerShell expression, evaluated (AllowExpression). +.PARAMETER Literal + A literal string, quoted as-is. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,params')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿงฎ')] +[ScriptOutput('Markdown')] +[CmdletBinding()] +param( + [Parameter(HelpMessage="A PowerShell expression, e.g. (2 + 3) * 4")] + [AllowExpression()] + $Expr = 42, + + [Parameter(HelpMessage="A literal string")] + [string]$Literal = "(2 + 3) * 4" +) + +Write-Host "# AllowExpression Test" +Write-Host "" +Write-Host "- **Expr** (evaluated): ``$Expr``" +Write-Host "- **Literal** (quoted): ``$Literal``" diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Parse-FailClosed.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Parse-FailClosed.ps1 new file mode 100644 index 0000000..33f9a76 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Parse-FailClosed.ps1 @@ -0,0 +1,23 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Parse: fail-closed (bad metadata) +.DESCRIPTION + Intentionally malformed metadata: ScriptTimeout is given a non-numeric value, which is a + critical attribute the parser cannot honor. EXPECT: the parser FAILS CLOSED โ€” this script + is shown as a disabled "repair" row (subtitle explains the bad ScriptTimeout value) and + must NOT be runnable. This guards the "don't silently run partially-understood metadata" + behavior. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,parse')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿงจ')] +[ScriptTimeout(not-a-number)] +[ScriptOutput('Toast')] +[CmdletBinding()] +param() + +Write-Host "If you can run this, fail-closed parsing did not trigger." diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-RequiresModule-Missing.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-RequiresModule-Missing.ps1 new file mode 100644 index 0000000..5c59b1d --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-RequiresModule-Missing.ps1 @@ -0,0 +1,21 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: RequiresModule (missing) +.DESCRIPTION + Declares a module that is not installed. EXPECT: the run-time preflight fails BEFORE the + body runs, with an Install-Module hint naming the missing module โ€” NOT the script's own + "term not recognized" error, and the marker line below must NOT run. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,module')] +[ScriptVersion('1.0.0')] +[ScriptIcon('๐Ÿ“ฆ')] +[ScriptOutput('Toast')] +[RequiresModule('PaletteShell.DefinitelyNotInstalled')] +[CmdletBinding()] +param() + +Write-Host "BODY RAN โ€” preflight did not block the missing module." diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Timeout.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Timeout.ps1 new file mode 100644 index 0000000..02c5483 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Timeout.ps1 @@ -0,0 +1,22 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Timeout: enforced kill +.DESCRIPTION + Declares a 2s [ScriptTimeout] but sleeps 30s. EXPECT: the run is terminated at ~2s and + surfaced as a timeout failure โ€” it must NOT run to completion or report success. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,timeout')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โฑ๏ธ')] +[ScriptTimeout(2000)] +[ScriptOutput('Toast')] +[CmdletBinding()] +param() + +Write-Host "Sleeping 30s โ€” should be killed at ~2s by ScriptTimeout(2000)." +Start-Sleep -Seconds 30 +Write-Host "THIS LINE MUST NOT APPEAR โ€” timeout was not enforced." diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Version-MaxTooLow.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Version-MaxTooLow.ps1 new file mode 100644 index 0000000..9979f8a --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Version-MaxTooLow.ps1 @@ -0,0 +1,21 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: RequiresPaletteShellMaximum (too low) +.DESCRIPTION + Sets a maximum PaletteShell version below the current app. EXPECT: the script is hidden + from the runnable list and shown as an explanatory row saying the installed app is too + new โ€” it must NOT be runnable. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,version')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โฌ‡๏ธ')] +[ScriptOutput('Toast')] +[RequiresPaletteShellMaximum('0.0.1')] +[CmdletBinding()] +param() + +Write-Host "If you can run this, the maximum-version gate is not enforced." diff --git a/PaletteShellExtension/IntegrationTestScripts/Test-Version-MinTooHigh.ps1 b/PaletteShellExtension/IntegrationTestScripts/Test-Version-MinTooHigh.ps1 new file mode 100644 index 0000000..924c562 --- /dev/null +++ b/PaletteShellExtension/IntegrationTestScripts/Test-Version-MinTooHigh.ps1 @@ -0,0 +1,21 @@ +๏ปฟusing module .\PaletteScriptAttributes.psm1 + +<# +.SYNOPSIS + [IT] Attr: RequiresPaletteShellMinimum (too high) +.DESCRIPTION + Requires an unrealistically high minimum PaletteShell version. EXPECT: the script is + hidden from the runnable list and shown as an explanatory row saying the installed app + is too old โ€” it must NOT be runnable. +#> +[ScriptHost('auto')] +[ScriptGroup('Integration Tests')] +[ScriptTags('integration,version')] +[ScriptVersion('1.0.0')] +[ScriptIcon('โฌ†๏ธ')] +[ScriptOutput('Toast')] +[RequiresPaletteShellMinimum('99.0.0')] +[CmdletBinding()] +param() + +Write-Host "If you can run this, the minimum-version gate is not enforced." diff --git a/PaletteShellExtension/Package.appxmanifest b/PaletteShellExtension/Package.appxmanifest index cca0ace..b7c9d50 100644 --- a/PaletteShellExtension/Package.appxmanifest +++ b/PaletteShellExtension/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="0.0.7.10" /> PaletteShell phireForge diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index a1dea86..a9d78f8 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -260,10 +260,12 @@ private static void CopyPowerShellModule(string root) } } - // Copy the agent-facing authoring spec so tools pointed at the scripts folder discover - // the script contract. Best-effort and always refreshed to stay in sync with the extension. + // Copy the short agent-facing guide and full reference so tools pointed at the scripts + // folder can start cheaply, then opt into the detailed contract when needed. var agentsSourcePath = Path.Combine(baseDir, "AGENTS.md"); var agentsTargetPath = Path.Combine(root, "AGENTS.md"); + var referenceSourcePath = Path.Combine(baseDir, "PaletteShellScripts.Reference.md"); + var referenceTargetPath = Path.Combine(root, "PaletteShellScripts.Reference.md"); if (File.Exists(agentsSourcePath)) { @@ -277,19 +279,15 @@ private static void CopyPowerShellModule(string root) } } - // Copy TextCopy.dll and its dependencies so PowerShell can load it - var textCopySource = Path.Combine(baseDir, "TextCopy.dll"); - var textCopyTarget = Path.Combine(root, "TextCopy.dll"); - - if (File.Exists(textCopySource)) + if (File.Exists(referenceSourcePath)) { try { - CopyIfChanged(textCopySource, textCopyTarget); + CopyIfChanged(referenceSourcePath, referenceTargetPath); } catch (Exception) { - // Scripts will fall back to Windows Forms clipboard. + // Authoring reference copy is best-effort. } } } @@ -377,6 +375,11 @@ public override IListItem[] GetItems() // by completion order, so results are identical to the sequential version. var scriptResults = new (bool Pinned, string Title, IListItem Item)?[_files.Count]; + // Probe for pwsh 7 and read the default host once, not per script: both feed the + // compatibility gate below, which stays a pure check so it can run in the parallel loop. + var pwshAvailable = ScriptRunner.IsPwshInstalled(); + var defaultHost = PaletteShellSettingsManager.Instance.DefaultHost; + Parallel.For(0, _files.Count, i => { var file = _files[i]; @@ -420,7 +423,7 @@ public override IListItem[] GetItems() // One gate covers app-version range and the elevation/output-capture conflict for // every route below: a blocked script gets a warning row instead of a runnable one. - var compat = ScriptCompatibility.Validate(manifest); + var compat = ScriptCompatibility.Validate(manifest, pwshAvailable, defaultHost); if (compat.Kind == ScriptCompatibilityKind.RequiresUpdate) { var incompatiblePinned = pins.IsPinned(path); @@ -450,6 +453,19 @@ public override IListItem[] GetItems() return; } + if (compat.Kind == ScriptCompatibilityKind.PwshMissing) + { + var pwshMissingPinned = pins.IsPinned(path); + scriptResults[i] = (pwshMissingPinned, title, new ListItem(new PwshMissingCommand()) + { + Title = title, + Subtitle = "โš  Requires PowerShell 7 (pwsh) โ€” not installed. Install from https://aka.ms/powershell, or set host to 'auto'", + Icon = new IconInfo(""), // Warning + MoreCommands = BuildContextCommands(path, pins) + }); + return; + } + if (compat.Kind == ScriptCompatibilityKind.ElevationIncompatible) { var elevationPinned = pins.IsPinned(path); @@ -502,11 +518,12 @@ public override IListItem[] GetItems() } else if (plan is not null && (plan.DeclaredTimeoutMs is not null || plan.SurfacesOutput)) { - // No parameters, but a waited mode (Toast/Clipboard/Open/File, or None with a - // declared timeout): run on the async ScriptRunPage so a slow script doesn't - // freeze the host on its blocking COM call. The page shows progress, runs the - // script off-thread, then dispatches the clipboard/toast/file/open behavior. - command = new ScriptRunPage(path, manifest!, plan); + // No parameters, but a waited ambient mode (Toast/Clipboard/Open/File, or None + // with a declared timeout): dismiss the palette and run off-thread, performing + // the clipboard/open/file side effect and toasting completion via a host banner โ€” + // so a fire-and-forget script neither freezes the host nor parks a page the user + // has to dismiss. Display modes (Result/Markdown/List) branched off above. + command = new AmbientRunCommand(path, manifest!, plan); } else { diff --git a/PaletteShellExtension/Pages/ScriptRunPage.cs b/PaletteShellExtension/Pages/ScriptRunPage.cs deleted file mode 100644 index 7754e80..0000000 --- a/PaletteShellExtension/Pages/ScriptRunPage.cs +++ /dev/null @@ -1,206 +0,0 @@ -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using PaletteShellExtension.Classes; -using PaletteShellExtension.Commands; -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace PaletteShellExtension.Pages; - -/// -/// Common asynchronous execution page for every "waited" no-parameter output mode โ€” Toast, -/// Clipboard, Open, File, and None with a declared timeout. The synchronous Invoke() runner -/// blocks the thread the host is making its COM call on for the whole run (up to ten minutes), -/// which freezes the palette. This page instead navigates immediately, shows a progress spinner, -/// runs the script on a background thread via , and -/// dispatches the clipboard/toast/file/open/result behavior when it finishes. Navigating away -/// disposes the page, which cancels the run and kills the child process. -/// -internal sealed partial class ScriptRunPage : ListPage, IDisposable -{ - private readonly string _scriptPath; - private readonly ScriptManifest _manifest; - private readonly ScriptExecutionPlan _plan; - - private IListItem[] _items = []; - private bool _started; - - // Set while a run is in flight so an incidental duplicate GetItems doesn't launch a second run. - private bool _running; - - // Set once a destructive script's confirmation has been accepted, so the run starts only then. - private bool _confirmed; - - // Cancelled (and the child process killed) when the page is disposed on navigate-away. - private readonly CancellationTokenSource _cts = new(); - - public ScriptRunPage(string scriptPath, ScriptManifest manifest, ScriptExecutionPlan plan) - { - _scriptPath = scriptPath; - _manifest = manifest; - _plan = plan; - - Title = manifest.Title ?? Path.GetFileNameWithoutExtension(scriptPath); - Name = "Run"; - Icon = new(manifest.IconGlyph ?? ""); - Id = $"ScriptRun_{Path.GetFileNameWithoutExtension(scriptPath)}"; - IsLoading = true; - } - - private bool NeedsConfirmation => !string.IsNullOrWhiteSpace(_manifest.ConfirmMessage); - - public override IListItem[] GetItems() - { - if (!_started) - { - _started = true; - - // Destructive scripts gate behind a confirmation dialog first; the run only starts once - // the user accepts. Everything else starts immediately on first fetch. - if (NeedsConfirmation && !_confirmed) - { - IsLoading = false; - _items = [BuildConfirmItem()]; - } - else - { - Start(); - } - } - - return _items; - } - - // A single row that surfaces the destructive-run confirmation. Enter shows the native confirm - // dialog; accepting it starts the run in place. - private ListItem BuildConfirmItem() - { - var scriptName = Path.GetFileNameWithoutExtension(_scriptPath); - return new ListItem(new CallbackCommand($"Run {scriptName}", () => CommandResult.Confirm(new ConfirmationArgs - { - Title = $"Run {scriptName}?", - Description = _manifest.ConfirmMessage ?? "", - PrimaryCommand = new CallbackCommand($"Run {scriptName}", () => - { - _confirmed = true; - Start(); - return CommandResult.KeepOpen(); - }), - IsPrimaryCommandCritical = true, - }))) - { - Title = $"Run {scriptName}?", - Subtitle = _manifest.ConfirmMessage ?? "", - Icon = new IconInfo(_manifest.IconGlyph ?? ""), - }; - } - - private void Start() - { - if (_running) - { - return; - } - - _running = true; - IsLoading = true; - _ = Task.Run(Execute); - } - - private async Task Execute() - { - try - { - var result = await ScriptExecutionService.RunAsync(_plan, cancellationToken: _cts.Token); - _items = BuildItems(result); - } - catch (OperationCanceledException) - { - // The page was navigated away from mid-run; the child was killed. Nothing to render. - return; - } - catch (Exception ex) - { - _items = [Message($"Error running script: {ex.Message}")]; - } - finally - { - _running = false; - IsLoading = false; - if (!_cts.IsCancellationRequested) - { - RaiseItemsChanged(); - } - } - } - - private IListItem[] BuildItems(ScriptRunner.ScriptResult? result) - { - // Failures get an actionable row (Enter opens the full failure report) instead of an inert - // message, so the user can see the whole error rather than a summary. - if (result is null || result.TimedOut || result.ExitCode != 0) - { - return [ScriptFailurePresenter.ToListItem(_scriptPath, _plan.Host, "", result)]; - } - - // Elevated runs can't capture output, so there's nothing to surface beyond completion. - var output = _plan.CaptureOutput ? result.StandardOutput : null; - var outcome = ScriptRunDispatcher.Apply(_manifest, output, Path.GetFileNameWithoutExtension(_scriptPath)); - - // A non-web, non-file open target: confirm the exact target before letting Windows launch - // whatever handler it resolves to. - if (outcome.UnsafeOpenTarget is { } target) - { - return - [ - new ListItem(new CallbackCommand("Open", () => - { - ScriptOutputHandler.OpenTarget(target); - return CommandResult.KeepOpen(); - })) - { - Title = target, - Subtitle = "Not a web link or a file on disk โ€” press Enter to open it", - Icon = new IconInfo(""), // Warning - }, - ]; - } - - // Clipboard/Toast leave something worth copying again; show it as a copyable result row. - if (outcome.CopyValue is { } value) - { - return - [ - new ListItem(new CopyValueCommand(value)) - { - Title = outcome.Status, - Subtitle = "Press Enter to copy to the clipboard", - Icon = new IconInfo(_manifest.IconGlyph ?? ""), - }, - ]; - } - - return [Message(outcome.Status)]; - } - - private static ListItem Message(string text) => new(new NoOpCommand()) { Title = text }; - - public void Dispose() - { - try - { - if (!_cts.IsCancellationRequested) - { - _cts.Cancel(); - } - } - catch (ObjectDisposedException) - { - // Already disposed โ€” nothing to cancel. - } - - _cts.Dispose(); - } -} diff --git a/PaletteShellExtension/PaletteScriptAttributes.psm1 b/PaletteShellExtension/PaletteScriptAttributes.psm1 index f9aa289..574b07d 100644 --- a/PaletteShellExtension/PaletteScriptAttributes.psm1 +++ b/PaletteShellExtension/PaletteScriptAttributes.psm1 @@ -1,4 +1,4 @@ -# PaletteScriptAttributes.psm1 +๏ปฟ# PaletteScriptAttributes.psm1 # Custom attributes for PaletteShell script metadata using namespace System @@ -36,13 +36,21 @@ class ScriptTimeoutAttribute : Attribute { # Output handling class ScriptOutputAttribute : Attribute { # None, Toast, Clipboard, Markdown, Result, List, Open, or File. - # File writes stdout to a temp file and opens it in the editor; append an - # extension hint after a colon to control the file type, e.g. 'File:csv' or 'File:json'. - # Result shows stdout as a single copyable result (Enter copies; a "Run again" - # command regenerates) โ€” like a calculator answer; good for generators. - # List parses stdout (newline-delimited, or a JSON array) into a searchable - # results page where each line/object becomes a selectable item. - # Open launches the first non-empty stdout line as a URL, file, or folder path. + # + # Two dispositions: + # Fire-and-forget (the palette closes and a completion banner is shown when done): + # None - run silently; report completion. + # Toast - show stdout in the completion banner. + # Clipboard - copy stdout to the clipboard. + # Open - launch the first non-empty stdout line as a URL, file, or folder path. + # File - write stdout to a temp file and open it in the editor; append an + # extension hint after a colon to set the file type, e.g. 'File:csv' or 'File:json'. + # In-palette (the palette stays open on a page that renders the output): + # Result - show stdout as a single copyable result (Enter copies; a "Run again" + # command regenerates) โ€” like a calculator answer; good for generators. + # Markdown - render stdout as Markdown. + # List - parse stdout (newline-delimited, or a JSON array) into a searchable + # results page where each line/object becomes a selectable item. [string]$Mode ScriptOutputAttribute([string]$mode) { $this.Mode = $mode } } @@ -111,13 +119,13 @@ class ScriptEnvAttribute : Attribute { } } -# Cross-platform clipboard functions using TextCopy +# Clipboard functions: built-in Get/Set-Clipboard first, Windows Forms as fallback. function Get-ClipboardText { <# .SYNOPSIS - Gets text from the clipboard in a cross-platform way. + Gets text from the clipboard. .DESCRIPTION - Uses TextCopy library for cross-platform clipboard access with Windows Forms fallback. + Uses the built-in Get-Clipboard cmdlet (PowerShell 5+) with a Windows Forms fallback. #> # Use built-in Get-Clipboard if available (PowerShell 5+) @@ -156,27 +164,6 @@ function Set-ClipboardText { # Built-in Set-Clipboard failed; fall through to the next method. } - # Try TextCopy - try { - $textCopyDll = Join-Path $PSScriptRoot 'TextCopy.dll' - - if (Test-Path $textCopyDll) { - $loaded = [AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetName().Name -eq 'TextCopy' } | - Select-Object -First 1 - - if (-not $loaded) { - $assembly = [System.Reflection.Assembly]::LoadFrom($textCopyDll) - } - - [TextCopy.ClipboardService]::SetText($Text) - return - } - } - catch { - # TextCopy failed; fall through to the Windows Forms fallback. - } - # Fallback to Windows Forms try { Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 40275f2..4c2f953 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -16,17 +16,25 @@ phireForge.PaletteShell phireForge - 0.0.7.0 + 0.0.7.10 true 655B3250FBBF0E38A4B98C8F44B7DD5E9A9DEC56 - - false + + + true + true @@ -37,6 +45,13 @@ + + + + + @@ -52,9 +67,12 @@ PreserveNewest - - + + + PreserveNewest + + PreserveNewest @@ -81,7 +99,6 @@ all runtime; build; native; contentfiles; analyzers - diff --git a/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs b/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs index f0d512b..ccd4ab9 100644 --- a/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs +++ b/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs @@ -5,6 +5,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using PaletteShellExtension.Classes; +using PaletteShellExtension.Pages; using System; namespace PaletteShellExtension; @@ -45,5 +46,4 @@ public override ICommandItem[] TopLevelCommands() { return _commands; } - } diff --git a/README.md b/README.md index 3d7270d..3951e39 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Short version: - Put `using module .\PaletteScriptAttributes.psm1` on line 1. - Put all `[Script*]` attributes above `param(...)`. - Provide `.SYNOPSIS`, `.DESCRIPTION`, and `.PARAMETER` help. -- Emit results on stdout for output modes that capture output. +- Emit results on stdout for output modes that capture output; exit non-zero so failures open the shared details flow. - Use clipboard, defaults, dropdowns, or live list providers instead of several heavy form inputs. ## License diff --git a/docs/PaletteShellScripts.AGENTS.md b/docs/PaletteShellScripts.AGENTS.md index 1d5bc67..8b8da78 100644 --- a/docs/PaletteShellScripts.AGENTS.md +++ b/docs/PaletteShellScripts.AGENTS.md @@ -34,7 +34,7 @@ param( - Put every `[Script*]` attribute above `param(...)`. - Use a `<# ... #>` help block with `.SYNOPSIS`, `.DESCRIPTION`, and `.PARAMETER` entries. - Save as UTF-8 with BOM when the script contains emoji or may run under Windows PowerShell 5.1. -- Emit captured results on stdout and exit non-zero on failure. +- Emit captured results on stdout and exit non-zero on failure; waited failures surface through the shared failure dialog/action. - Scripts live at the top level of the scripts folder; subfolders are not scanned. - Changes appear after the user runs `Reload scripts`. diff --git a/docs/PaletteShellScripts.Reference.md b/docs/PaletteShellScripts.Reference.md index 98c39a9..a3d3cb0 100644 --- a/docs/PaletteShellScripts.Reference.md +++ b/docs/PaletteShellScripts.Reference.md @@ -96,7 +96,7 @@ Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything e Set with `[ScriptOutput('')]`. Default is `None`. Modes have two **dispositions**: -**Ambient (fire-and-forget)** โ€” perform the side effect, show a toast, and **dismiss the palette**. For quick scripts whose payoff is external or nil. +**Ambient (fire-and-forget)** โ€” perform the side effect, show a success toast, and **dismiss the palette**. For quick scripts whose payoff is external or nil. | Mode | Behavior | |------|----------| @@ -117,6 +117,8 @@ Set with `[ScriptOutput('')]`. Default is `None`. Modes have two **disposi Anything other than `None`-with-no-`[ScriptTimeout]` makes PaletteShell **wait** for the process, up to the timeout (30s default) before killing it. So: emit results on **stdout** (`Write-Host` / `Write-Output` are captured), keep the run under the timeout, and exit non-zero on failure. +Waited failures use the shared failure dialog/action with **View details** instead of a transient +toast, regardless of output mode. Ambient scripts run synchronously and hold the palette until they finish, so keep them **quick**; anything slow or with rich output belongs in a display mode (`Markdown`/`Result`/`List`). diff --git a/docs/extension-behavior-reference.md b/docs/extension-behavior-reference.md index 86b77ef..00203e0 100644 --- a/docs/extension-behavior-reference.md +++ b/docs/extension-behavior-reference.md @@ -64,11 +64,13 @@ If a script declares `[ConfirmBeforeRun('message')]`, selecting it first shows a Output modes fall into two **dispositions** by where the result lives: -- **Ambient / fire-and-forget** (`None`, `Toast`, `Clipboard`, `Open`, `File`) โ€” the payoff is external or nil (a file/URL opens, the clipboard is set, or nothing). PaletteShell performs the side effect, shows a toast, and **dismisses the palette**. These are for quick scripts. +- **Ambient / fire-and-forget** (`None`, `Toast`, `Clipboard`, `Open`, `File`) โ€” the payoff is external or nil (a file/URL opens, the clipboard is set, or nothing). PaletteShell performs the side effect, shows a success toast, and **dismisses the palette**. These are for quick scripts. - **In-palette / display** (`Markdown`, `Result`, `List`) โ€” the payoff is text worth reading, so PaletteShell opens a page that **stays open** and renders it. PaletteShell waits (up to the declared timeout, or the [default timeout setting](#settings), 30s unless changed) and captures stdout/stderr for every mode except pure `None` with no `[ScriptTimeout]`, which is launched fire-and-forget without waiting. +If a waited run fails to start, times out, exits non-zero, or cannot complete its output side effect, PaletteShell surfaces the failure through the shared failure dialog/action instead of a transient toast. The dialog includes a short summary and a **View details** action that opens the full failure report. + | Condition | Behavior | |-----------|----------| | `[ScriptOutput('None')]` and no `[ScriptTimeout]` | Fire-and-forget โ€” the process is started, a "Script completed" toast is shown, and the palette dismisses. | From d7417e2660c2f578ba40211624c646896c7f2289 Mon Sep 17 00:00:00 2001 From: phireForge <269562005+phireForge@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:39:13 -0400 Subject: [PATCH 21/21] fix ci action --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0659339..e2d52ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: run: dotnet restore PaletteShellExtension.sln - name: Build - run: dotnet build PaletteShellExtension.sln -c Release -p:Platform=${{ matrix.platform }} --no-restore + run: dotnet build PaletteShellExtension.sln -c Release -p:Platform=${{ matrix.platform }} -p:AppxPackageSigningEnabled=false --no-restore test: name: Test @@ -46,7 +46,7 @@ jobs: 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" + run: dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 -p:AppxPackageSigningEnabled=false --no-restore --logger "trx;LogFileName=test-results.trx" - name: Upload test results if: always()