diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..22c9873
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# Used for PR review requests.
+* @paletteshell
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()
diff --git a/.gitignore b/.gitignore
index bbd4c2d..2349953 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,7 +132,9 @@ BundleArtifacts/
# Azure / publish
# =========================
*.publish.xml
-*.pubxml
+# .pubxml publish profiles are project config referenced by the csproj
+# (win-$(Platform).pubxml), so CI needs them
+# in the repo; only the user-specific .pubxml.user is ignored (via *.user).
*.publishproj
PublishScripts/
@@ -167,3 +169,8 @@ BenchmarkDotNet.Artifacts/
# =========================
!Directory.Build.rsp
/PaletteShellExtension/bundle_mapping.txt
+signing/
+/PaletteShellExtension/*.msixbundle
+/.github/skills
+/.agents/skills
+/agent/skills
diff --git a/AGENTS.md b/AGENTS.md
index 5bbf125..f0934f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,238 +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, shown as a tag and used for grouping |
-| `[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 |
-| `[RequiresElevation()]` | Run elevated (admin). Equivalent to `#Requires -RunAsAdministrator`. Output capture is unavailable when elevated. |
-| `[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). |
-
-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
+## Project Map
-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**:
+- `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.
-- **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.
+## Validation
-So every piece of context a script needs must arrive through one of these channels, each with a cost:
-
-| 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) |
-
-### The working-directory problem, concretely
-
-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
-```
-
-## 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`, `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/AmbientRunnerTests.cs b/PaletteShellExtension.Tests/AmbientRunnerTests.cs
new file mode 100644
index 0000000..81100a8
--- /dev/null
+++ b/PaletteShellExtension.Tests/AmbientRunnerTests.cs
@@ -0,0 +1,87 @@
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+using PaletteShellExtension.Classes;
+using Xunit;
+
+namespace PaletteShellExtension.Tests;
+
+public class AmbientRunnerTests
+{
+ private const string ScriptPath = @"C:\scripts\toast.ps1";
+
+ private static ScriptExecutionPlan ToastPlan() => new()
+ {
+ ScriptPath = ScriptPath,
+ Host = "pwsh",
+ Env = new(),
+ OutputMode = "Toast",
+ };
+
+ private static ScriptManifest ToastManifest() => new()
+ {
+ Output = "Toast",
+ Title = "Toast",
+ };
+
+ [Fact]
+ public void ResultForCompletedRun_NullStartResult_ShowsFailureDialog()
+ {
+ var result = AmbientRunner.ResultForCompletedRun(
+ ToastPlan(),
+ ToastManifest(),
+ null);
+
+ var args = AssertFailureConfirm(result);
+ Assert.Contains("failed to start", args.Title);
+ Assert.Equal("The script process could not be started.", args.Description);
+ }
+
+ [Fact]
+ public void ResultForCompletedRun_Timeout_ShowsFailureDialog()
+ {
+ var result = AmbientRunner.ResultForCompletedRun(
+ ToastPlan(),
+ ToastManifest(),
+ new ScriptRunner.ScriptResult { TimedOut = true });
+
+ var args = AssertFailureConfirm(result);
+ Assert.Contains("timed out and was killed", args.Title);
+ }
+
+ [Fact]
+ public void ResultForCompletedRun_NonZeroExit_ShowsFailureDialog()
+ {
+ var result = AmbientRunner.ResultForCompletedRun(
+ ToastPlan(),
+ ToastManifest(),
+ new ScriptRunner.ScriptResult { ExitCode = 7, StandardError = "boom" });
+
+ var args = AssertFailureConfirm(result);
+ Assert.Contains("exited with code 7", args.Title);
+ Assert.Contains("boom", args.Description);
+ }
+
+ [Fact]
+ public void ResultForCompletedRun_SuccessfulToast_StillShowsToast()
+ {
+ var result = AmbientRunner.ResultForCompletedRun(
+ ToastPlan(),
+ ToastManifest(),
+ new ScriptRunner.ScriptResult { ExitCode = 0, StandardOutput = "hello" });
+
+ Assert.Equal(CommandResultKind.ShowToast, result.Kind);
+ var args = Assert.IsType(result.Args);
+ Assert.Equal("hello", args.Message);
+ }
+
+ // The Command Palette host's command-result switch (ShellViewModel) has no GoToPage case, so
+ // a failure returned from Invoke() must be a Confirm dialog — the kind the host actually
+ // renders — not a page navigation.
+ private static ConfirmationArgs AssertFailureConfirm(CommandResult result)
+ {
+ Assert.Equal(CommandResultKind.Confirm, result.Kind);
+ var args = Assert.IsType(result.Args);
+ Assert.NotNull(args.PrimaryCommand);
+ return args;
+ }
+}
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..51a6901
--- /dev/null
+++ b/PaletteShellExtension.Tests/InstalledCommunityScriptsTests.cs
@@ -0,0 +1,97 @@
+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 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..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)
@@ -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()
{
@@ -436,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 ---------------------------------------------------------------
@@ -527,4 +682,34 @@ public void ResolveCwd_ScriptDirOfRealScript_Resolves()
Assert.Equal(expectedDir, result);
}
+
+ // ----- Metadata read cap --------------------------------------------------------------
+
+ [Fact]
+ public void LargeScript_MetadataAtTop_StillParses()
+ {
+ // The parser caps its read at the head of the file; a script with a huge body must
+ // still have its header metadata (help, attributes, params) parsed correctly.
+ var header = """
+ <#
+ .SYNOPSIS
+ Big Script
+ #>
+ [ScriptOutput('Clipboard')]
+ param(
+ [string]$Name = 'x'
+ )
+
+ """;
+ var body = string.Concat(Enumerable.Repeat("Write-Output 'padding line to inflate the body well past the metadata read cap'\n", 5000));
+ using var file = new TestScriptFile(header + body);
+
+ var manifest = PowerShellScriptParser.TryParseManifest(file.Path);
+
+ Assert.NotNull(manifest);
+ Assert.Equal("Big Script", manifest!.Title);
+ Assert.Equal("Clipboard", manifest.Output);
+ Assert.Single(manifest.Parameters);
+ Assert.Equal("Name", manifest.Parameters[0].Name);
+ }
}
diff --git a/PaletteShellExtension.Tests/ResilienceTests.cs b/PaletteShellExtension.Tests/ResilienceTests.cs
new file mode 100644
index 0000000..e54bd42
--- /dev/null
+++ b/PaletteShellExtension.Tests/ResilienceTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.IO;
+using System.Linq;
+using PaletteShellExtension.Classes;
+using Xunit;
+
+namespace PaletteShellExtension.Tests;
+
+// Regression tests for the Partner Center crash class: failures on the COM activation
+// path (bad scripts folder, corrupt settings file) must degrade, never throw.
+public class ResilienceTests
+{
+ // ----- Corrupt settings quarantine ----------------------------------------------------
+
+ [Fact]
+ public void QuarantineCorruptSettingsFile_InvalidJson_MovesFileToBad()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"palette-test-{Guid.NewGuid():N}.json");
+ File.WriteAllText(path, "{ not valid json !!");
+ try
+ {
+ PaletteShellSettingsManager.QuarantineCorruptSettingsFile(path);
+
+ Assert.False(File.Exists(path));
+ Assert.True(File.Exists(path + ".bad"));
+ }
+ finally
+ {
+ File.Delete(path);
+ File.Delete(path + ".bad");
+ }
+ }
+
+ [Fact]
+ public void QuarantineCorruptSettingsFile_ValidJson_LeavesFileAlone()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"palette-test-{Guid.NewGuid():N}.json");
+ File.WriteAllText(path, """{"defaultHost":"auto"}""");
+ try
+ {
+ PaletteShellSettingsManager.QuarantineCorruptSettingsFile(path);
+
+ Assert.True(File.Exists(path));
+ Assert.False(File.Exists(path + ".bad"));
+ }
+ finally
+ {
+ File.Delete(path);
+ File.Delete(path + ".bad");
+ }
+ }
+
+ [Fact]
+ public void QuarantineCorruptSettingsFile_MissingOrNullPath_IsANoOp()
+ {
+ PaletteShellSettingsManager.QuarantineCorruptSettingsFile(null);
+ PaletteShellSettingsManager.QuarantineCorruptSettingsFile("");
+ PaletteShellSettingsManager.QuarantineCorruptSettingsFile(
+ Path.Combine(Path.GetTempPath(), $"palette-missing-{Guid.NewGuid():N}.json"));
+ }
+
+ // ----- Inaccessible scripts folder ----------------------------------------------------
+
+ [Fact]
+ public void Page_ConfiguredFolderOnMissingDrive_DegradesToSetupItemInsteadOfThrowing()
+ {
+ // A drive letter that doesn't exist on this machine stands in for the unplugged
+ // USB stick / offline share from the field reports.
+ var used = DriveInfo.GetDrives()
+ .Select(d => char.ToUpperInvariant(d.Name[0]))
+ .ToHashSet();
+ var letter = "ZYXWVUTSRQPONMLKJIHGFEDCBA".First(c => !used.Contains(c));
+ var badFolder = $@"{letter}:\PaletteShellTests\{Guid.NewGuid():N}";
+
+ var original = PaletteShellSettingsManager.Instance.ScriptsFolder;
+ try
+ {
+ PaletteShellSettingsManager.Instance.ScriptsFolder = badFolder;
+
+ // Would previously throw DirectoryNotFoundException out of the constructor —
+ // i.e. crash the extension process during COM activation.
+ var page = new PaletteShellExtensionPage();
+ var items = page.GetItems();
+
+ var item = Assert.Single(items);
+ Assert.Equal("Choose scripts folder", item.Title);
+ Assert.Contains(badFolder, item.Subtitle, StringComparison.Ordinal);
+ }
+ finally
+ {
+ PaletteShellSettingsManager.Instance.ScriptsFolder = original;
+ }
+ }
+}
diff --git a/PaletteShellExtension.Tests/SampleScriptInstallerTests.cs b/PaletteShellExtension.Tests/SampleScriptInstallerTests.cs
new file mode 100644
index 0000000..86257db
--- /dev/null
+++ b/PaletteShellExtension.Tests/SampleScriptInstallerTests.cs
@@ -0,0 +1,183 @@
+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", "hash1");
+
+ Assert.True(store.TryGet("Foo.ps1", out var record));
+ 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", "hash1");
+
+ var reloaded = new InstalledSampleScripts(root);
+
+ Assert.True(reloaded.TryGet("Foo.ps1", out var record));
+ 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 _));
+ Assert.Null(store.SyncedAppVersion);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void FreshStore_HasNoSyncedVersion()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ Assert.Null(new InstalledSampleScripts(root).SyncedAppVersion);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void MarkSynced_PersistsVersionAndRecordsAcrossInstances()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ var store = new InstalledSampleScripts(root);
+ store.Record("Foo.ps1", "hash1", persist: false);
+ store.MarkSynced("1.2.3.0");
+
+ var reloaded = new InstalledSampleScripts(root);
+
+ Assert.Equal("1.2.3.0", reloaded.SyncedAppVersion);
+ Assert.True(reloaded.TryGet("Foo.ps1", out var record));
+ Assert.Equal("hash1", record!.ContentHash);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void LegacyFlatFormat_LoadsRecordsWithoutSyncedVersion()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ // The pre-stamp format was the per-script map at the top level.
+ File.WriteAllText(
+ Path.Combine(root, "sample-scripts.json"),
+ """{ "Foo.ps1": { "hash": "hash1" } }""");
+
+ var store = new InstalledSampleScripts(root);
+
+ Assert.Null(store.SyncedAppVersion);
+ Assert.True(store.TryGet("Foo.ps1", out var record));
+ Assert.Equal("hash1", record!.ContentHash);
+ }
+ 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, shippedHash: "hash1"));
+ }
+
+ [Fact]
+ public void ShouldOverwrite_UntrackedExistingFile_ReturnsFalse()
+ {
+ Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record: null, onDiskHash: "somehash", shippedHash: "newHash"));
+ }
+
+ [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("originalHash");
+
+ Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: false, record, onDiskHash: null, shippedHash: "newHash"));
+ }
+
+ [Fact]
+ public void ShouldOverwrite_UserModifiedFile_ReturnsFalse()
+ {
+ var record = new InstalledSampleScript("originalHash");
+
+ Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "editedHash", shippedHash: "newHash"));
+ }
+
+ [Fact]
+ public void ShouldOverwrite_UnmodifiedWithChangedShippedContent_ReturnsTrue()
+ {
+ var record = new InstalledSampleScript("originalHash");
+
+ Assert.True(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "originalHash", shippedHash: "newHash"));
+ }
+
+ [Fact]
+ public void ShouldOverwrite_UnmodifiedWithUnchangedShippedContent_ReturnsFalse()
+ {
+ var record = new InstalledSampleScript("originalHash");
+
+ Assert.False(SampleScriptSync.ShouldOverwrite(targetExists: true, record, onDiskHash: "originalHash", shippedHash: "originalHash"));
+ }
+}
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.Tests/ScriptFailureFormTests.cs b/PaletteShellExtension.Tests/ScriptFailureFormTests.cs
new file mode 100644
index 0000000..9a73397
--- /dev/null
+++ b/PaletteShellExtension.Tests/ScriptFailureFormTests.cs
@@ -0,0 +1,115 @@
+using PaletteShellExtension.Classes;
+using PaletteShellExtension.Forms;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using Xunit;
+
+namespace PaletteShellExtension.Tests;
+
+public class ScriptFailureFormTests
+{
+ [Fact]
+ public void TemplateJson_ResultFailure_IncludesContextPreviewsAndActions()
+ {
+ var form = new ScriptFailureForm(
+ @"C:\scripts\report.ps1",
+ "pwsh",
+ "-Token 'secret' -Name 'Ada'",
+ new ScriptRunner.ScriptResult
+ {
+ ExitCode = 7,
+ StandardError = "boom",
+ StandardOutput = "partial output",
+ TimedOut = true,
+ DurationMs = 123,
+ });
+
+ var card = ParseCard(form);
+ var text = card.ToJsonString();
+ var facts = Facts(card);
+
+ Assert.Contains("report timed out and was killed", text);
+ Assert.Equal(ScriptRunner.DescribeShell("pwsh"), facts["Shell"]);
+ Assert.Equal("-Token '***' -Name 'Ada'", facts["Args"]);
+ Assert.Equal("7", facts["Exit code"]);
+ Assert.Equal("Yes", facts["Timed out"]);
+ Assert.Equal("123 ms", facts["Duration"]);
+ Assert.Contains("stderr preview", text);
+ Assert.Contains("boom", text);
+ Assert.Contains("stdout preview", text);
+ Assert.Contains("partial output", text);
+ Assert.DoesNotContain("secret", text);
+ AssertActions(card);
+ }
+
+ [Fact]
+ public void TemplateJson_ResultFailure_OmitsEmptyStreamPreviews()
+ {
+ var form = new ScriptFailureForm(
+ @"C:\scripts\report.ps1",
+ "pwsh",
+ "",
+ new ScriptRunner.ScriptResult { ExitCode = 1 });
+
+ var text = ParseCard(form).ToJsonString();
+
+ Assert.DoesNotContain("stderr preview", text);
+ Assert.DoesNotContain("stdout preview", text);
+ }
+
+ [Fact]
+ public void TemplateJson_ExceptionFailure_IncludesExceptionContextAndActions()
+ {
+ var form = new ScriptFailureForm(
+ @"C:\scripts\report.ps1",
+ "powershell",
+ "-ApiKey 'secret' -User 'Ada'",
+ new InvalidOperationException("output side effect failed"));
+
+ var card = ParseCard(form);
+ var text = card.ToJsonString();
+ var facts = Facts(card);
+
+ Assert.Contains("report failed: output side effect failed", text);
+ Assert.Equal(ScriptRunner.DescribeShell("powershell"), facts["Shell"]);
+ Assert.Equal("-ApiKey '***' -User 'Ada'", facts["Args"]);
+ Assert.Equal("InvalidOperationException", facts["Exception"]);
+ Assert.Contains("exception preview", text);
+ Assert.Contains("output side effect failed", text);
+ Assert.DoesNotContain("secret", text);
+ AssertActions(card);
+ }
+
+ private static JsonObject ParseCard(ScriptFailureForm form) =>
+ JsonNode.Parse(form.TemplateJson)!.AsObject();
+
+ private static Dictionary Facts(JsonObject card)
+ {
+ var factSet = card["body"]!.AsArray()
+ .Select(node => node!.AsObject())
+ .First(node => string.Equals(node["type"]?.ToString(), "FactSet", StringComparison.Ordinal));
+
+ return factSet["facts"]!.AsArray()
+ .Select(node => node!.AsObject())
+ .ToDictionary(
+ node => node["title"]!.ToString(),
+ node => node["value"]!.ToString(),
+ StringComparer.Ordinal);
+ }
+
+ private static void AssertActions(JsonObject card)
+ {
+ var actions = card["actions"]!.AsArray()
+ .Select(node => node!.AsObject())
+ .ToDictionary(
+ node => node["title"]!.ToString(),
+ node => node["data"]!["verb"]!.ToString(),
+ StringComparer.Ordinal);
+
+ Assert.Equal("viewDetails", actions["View details"]);
+ Assert.Equal("openLogs", actions["Open logs"]);
+ Assert.Equal("openReports", actions["Open reports"]);
+ }
+}
diff --git a/PaletteShellExtension.Tests/ScriptFailureReportTests.cs b/PaletteShellExtension.Tests/ScriptFailureReportTests.cs
new file mode 100644
index 0000000..aa92c76
--- /dev/null
+++ b/PaletteShellExtension.Tests/ScriptFailureReportTests.cs
@@ -0,0 +1,134 @@
+using System;
+using PaletteShellExtension.Classes;
+using Xunit;
+
+namespace PaletteShellExtension.Tests;
+
+public class ScriptFailureReportTests
+{
+ [Fact]
+ public void DescribeOutcome_NullResult_SaysFailedToStart()
+ {
+ Assert.Equal("failed to start", ScriptFailureReport.DescribeOutcome((ScriptRunner.ScriptResult?)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", "", (ScriptRunner.ScriptResult?)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 DescribeOutcome_Exception_IncludesMessage()
+ {
+ var exception = new InvalidOperationException("clipboard unavailable");
+
+ Assert.Equal("failed: clipboard unavailable", ScriptFailureReport.DescribeOutcome(exception));
+ }
+
+ [Fact]
+ public void Build_ExceptionFailure_IncludesPathArgsAndException()
+ {
+ var exception = new InvalidOperationException("clipboard unavailable");
+
+ var report = ScriptFailureReport.Build(@"C:\Scripts\foo.ps1", "pwsh", "-Token 'secret'", exception);
+
+ Assert.Contains(@"C:\Scripts\foo.ps1", report);
+ Assert.Contains("-Token '***'", report);
+ Assert.Contains("failed: clipboard unavailable", report);
+ Assert.Contains("InvalidOperationException", report);
+ Assert.DoesNotContain("secret", 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.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.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/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.sln b/PaletteShellExtension.sln
index 4969403..366d622 100644
--- a/PaletteShellExtension.sln
+++ b/PaletteShellExtension.sln
@@ -7,16 +7,47 @@ 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
Debug|x64 = Debug|x64
- Debug|x86 = Debug|x86
- Debug|Any CPU = Debug|Any CPU
Release|ARM64 = Release|ARM64
Release|x64 = Release|x64
- Release|x86 = Release|x86
- Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|ARM64.ActiveCfg = Debug|ARM64
@@ -25,42 +56,31 @@ Global
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x64.ActiveCfg = Debug|x64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x64.Build.0 = Debug|x64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x64.Deploy.0 = Debug|x64
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.ActiveCfg = Debug|x86
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Build.0 = Debug|x86
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Deploy.0 = Debug|x86
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.ActiveCfg = Release|ARM64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Build.0 = Release|ARM64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Deploy.0 = Release|ARM64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x64.ActiveCfg = Release|x64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x64.Build.0 = Release|x64
{79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x64.Deploy.0 = Release|x64
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.ActiveCfg = Release|x86
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Build.0 = Release|x86
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Deploy.0 = Release|x86
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|Any CPU.Build.0 = Release|Any CPU
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|ARM64.ActiveCfg = Debug|ARM64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|ARM64.Build.0 = Debug|ARM64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x64.ActiveCfg = Debug|x64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x64.Build.0 = Debug|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x86.ActiveCfg = Debug|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x86.Build.0 = Debug|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|Any CPU.ActiveCfg = Debug|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|Any CPU.Build.0 = Debug|x64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|ARM64.ActiveCfg = Release|ARM64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|ARM64.Build.0 = Release|ARM64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x64.ActiveCfg = Release|x64
{B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x64.Build.0 = Release|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x86.ActiveCfg = Release|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x86.Build.0 = Release|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|Any CPU.ActiveCfg = Release|x64
- {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
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..8ccf10a
--- /dev/null
+++ b/PaletteShellExtension/Classes/AmbientRunner.cs
@@ -0,0 +1,85 @@
+using Microsoft.CommandPalette.Extensions.Toolkit;
+using PaletteShellExtension.Commands;
+using System;
+using System.IO;
+
+namespace PaletteShellExtension.Classes;
+
+///
+/// Runs an ambient (fire-and-forget) script and returns a reporting the
+/// outcome. Ambient modes' payoff is external or nil (set the clipboard, open a target, open output
+/// in an editor, or nothing), so successful runs are surfaced as toasts while terminal failures use
+/// the common failure dialog.
+///
+/// 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 returned result 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 args = "")
+ {
+ ScriptRunner.ScriptResult? result;
+ try
+ {
+ result = ScriptExecutionService.RunAndWait(plan, args);
+ }
+ catch (Exception ex)
+ {
+ return ScriptFailurePresenter.ToCommandResult(plan.ScriptPath, plan.Host, args, ex);
+ }
+
+ if (result is null || result.TimedOut || result.ExitCode != 0)
+ {
+ return ScriptFailurePresenter.ToCommandResult(plan.ScriptPath, plan.Host, args, result);
+ }
+
+ return ResultForCompletedRun(plan, manifest, result, args);
+ }
+
+ internal static CommandResult ResultForCompletedRun(
+ ScriptExecutionPlan plan,
+ ScriptManifest manifest,
+ ScriptRunner.ScriptResult? result,
+ string args = "")
+ {
+ if (result is null || result.TimedOut || result.ExitCode != 0)
+ {
+ return ScriptFailurePresenter.ToCommandResult(plan.ScriptPath, plan.Host, args, result);
+ }
+
+ ScriptRunDispatcher.RunOutcome outcome;
+ try
+ {
+ // 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 scriptName = Path.GetFileNameWithoutExtension(plan.ScriptPath);
+ var output = plan.CaptureOutput ? result.StandardOutput : null;
+ outcome = ScriptRunDispatcher.Apply(manifest, output, scriptName);
+ }
+ catch (Exception ex)
+ {
+ return ScriptFailurePresenter.ToCommandResult(plan.ScriptPath, plan.Host, args, ex);
+ }
+
+ // 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);
+}
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/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/EditorLauncher.cs b/PaletteShellExtension/Classes/EditorLauncher.cs
index c7bb103..73d5b9d 100644
--- a/PaletteShellExtension/Classes/EditorLauncher.cs
+++ b/PaletteShellExtension/Classes/EditorLauncher.cs
@@ -16,13 +16,26 @@ 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
?? 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}");
+ }
}
///
@@ -33,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)}";
@@ -45,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/InstalledCommunityScripts.cs b/PaletteShellExtension/Classes/InstalledCommunityScripts.cs
new file mode 100644
index 0000000..88b20d3
--- /dev/null
+++ b/PaletteShellExtension/Classes/InstalledCommunityScripts.cs
@@ -0,0 +1,117 @@
+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.
+internal sealed record InstalledCommunityScript(string SourcePath, string Sha);
+
+///
+/// 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 static readonly JsonSerializerOptions SaveOptions = new() { WriteIndented = true };
+
+ 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)
+ {
+ _installed[KeyFor(localPath)] = new InstalledCommunityScript(sourcePath, sha ?? "");
+ 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() ?? "");
+ }
+ }
+
+ 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))
+ {
+ root[key] = new JsonObject
+ {
+ ["sourcePath"] = value.SourcePath,
+ ["sha"] = value.Sha,
+ };
+ }
+
+ File.WriteAllText(_storePath, root.ToJsonString(SaveOptions));
+ }
+ catch (Exception)
+ {
+ // Best-effort: an in-memory record still takes effect this session even if it can't persist.
+ }
+ }
+}
diff --git a/PaletteShellExtension/Classes/Log.cs b/PaletteShellExtension/Classes/Log.cs
index 1566cb7..c03e7b5 100644
--- a/PaletteShellExtension/Classes/Log.cs
+++ b/PaletteShellExtension/Classes/Log.cs
@@ -13,21 +13,28 @@ namespace PaletteShellExtension.Classes;
internal static class Log
{
private static readonly object WriteLock = new();
- private static readonly string LogPath;
+
+ /// 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
+ // malformed scripts — doesn't pay a full file open/close per line.
+ private static StreamWriter? _writer;
+ private static string _writerDate = "";
static Log()
{
- var dir = Path.Combine(
+ LogDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PaletteShell",
"logs");
- LogPath = Path.Combine(dir, $"palette-shell-{DateTime.Now:yyyyMMdd}.log");
-
try
{
- Directory.CreateDirectory(dir);
- PruneOldLogs(dir);
+ Directory.CreateDirectory(LogDirectory);
+ PruneOldLogs(LogDirectory);
}
catch (Exception)
{
@@ -46,10 +53,10 @@ private static void Write(string level, string message)
{
try
{
- var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}";
+ var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}";
lock (WriteLock)
{
- File.AppendAllText(LogPath, line);
+ GetWriter()?.WriteLine(line);
}
}
catch (Exception)
@@ -58,6 +65,41 @@ private static void Write(string level, string message)
}
}
+ // Returns the shared writer, opening (or rolling over) the day's file as needed.
+ // Must be called under WriteLock. A failed open leaves the writer null and the date
+ // marker unset, so the next write retries — same failure-mode behavior as the old
+ // open-per-line approach.
+ private static StreamWriter? GetWriter()
+ {
+ var today = DateTime.Now.ToString("yyyyMMdd");
+ if (today == _writerDate)
+ {
+ return _writer;
+ }
+
+ try { _writer?.Dispose(); }
+ catch (Exception) { }
+ _writer = null;
+ _writerDate = "";
+
+ try
+ {
+ // FileShare.ReadWrite keeps the held handle from locking out another process
+ // (or the user tailing the log) the way the old transient appends never did.
+ var stream = new FileStream(
+ Path.Combine(LogDirectory, $"palette-shell-{today}.log"),
+ FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
+ _writer = new StreamWriter(stream) { AutoFlush = true };
+ _writerDate = today;
+ }
+ catch (Exception)
+ {
+ // Couldn't open the log file; stay a no-op and retry on the next write.
+ }
+
+ return _writer;
+ }
+
// Keeps the log directory from growing forever — one file per day, last week retained.
private static void PruneOldLogs(string dir)
{
diff --git a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs
index d4f9bb5..0db9c48 100644
--- a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs
+++ b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs
@@ -1,6 +1,8 @@
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using System.Text.Json.Nodes;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace PaletteShellExtension.Classes;
@@ -19,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(
@@ -50,16 +59,83 @@ 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;
- public int DefaultTimeoutMs =>
- int.TryParse(_defaultTimeoutMs.Value, out var ms) && ms > 0 ? ms : FallbackTimeoutMs;
+ /// The user-configured default timeout, clamped to the same bounds a
+ /// script-declared [ScriptTimeout(...)] gets, so a typo'd setting (an extra
+ /// digit, say) can't turn every run into a multi-minute blocked COM call.
+ public int DefaultTimeoutMs
+ {
+ get
+ {
+ if (!int.TryParse(_defaultTimeoutMs.Value, out var ms) || ms <= 0)
+ {
+ return FallbackTimeoutMs;
+ }
+
+ if (ms < PowerShellScriptParser.MinTimeoutMs)
+ {
+ Log.Warn($"Clamping default timeout setting ({ms}) to the {PowerShellScriptParser.MinTimeoutMs}ms minimum");
+ return PowerShellScriptParser.MinTimeoutMs;
+ }
+
+ if (ms > PowerShellScriptParser.MaxTimeoutMs)
+ {
+ Log.Warn($"Clamping default timeout setting ({ms}) to the {PowerShellScriptParser.MaxTimeoutMs}ms maximum");
+ return PowerShellScriptParser.MaxTimeoutMs;
+ }
+
+ return ms;
+ }
+ }
/// The user's preferred editor command/path, or null when unset (fall back to env vars/Notepad).
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
{
@@ -73,17 +149,69 @@ public string? ScriptsFolder
private PaletteShellSettingsManager()
{
- var settingsDir = Utilities.BaseSettingsPath("PaletteShellExtension");
- Directory.CreateDirectory(settingsDir);
- FilePath = Path.Combine(settingsDir, "settings.json");
+ // Instance is a static initializer, so anything thrown here would surface as a
+ // TypeInitializationException at first touch — during COM activation — and kill
+ // the process on every launch. Settings are a convenience, not a requirement:
+ // on any failure, log and run this session with defaults instead of dying.
+ try
+ {
+ var settingsDir = Utilities.BaseSettingsPath("PaletteShellExtension");
+ Directory.CreateDirectory(settingsDir);
+ FilePath = Path.Combine(settingsDir, "settings.json");
+ }
+ catch (Exception ex)
+ {
+ Log.Error("Failed to prepare the settings folder; settings won't persist this session", ex);
+ }
Settings.Add(_defaultHost);
Settings.Add(_defaultTimeoutMs);
Settings.Add(_preferredEditor);
Settings.Add(_scriptsFolder);
+ Settings.Add(_cleanupTempEnabled);
+ Settings.Add(_cleanupTempDays);
- LoadSettings();
+ try
+ {
+ QuarantineCorruptSettingsFile(FilePath);
+ LoadSettings();
+ }
+ catch (Exception ex)
+ {
+ Log.Error("Failed to load settings; continuing with defaults", ex);
+ }
Settings.SettingsChanged += (_, _) => SaveSettings();
}
+
+ ///
+ /// If settings.json exists but isn't parseable JSON (torn write, disk hiccup),
+ /// renames it to settings.json.bad so this and every future launch start clean
+ /// with defaults, instead of tripping over the same corrupt file at each activation.
+ /// The rename (rather than delete) keeps the evidence for diagnosis.
+ ///
+ internal static void QuarantineCorruptSettingsFile(string? filePath)
+ {
+ if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
+ {
+ return;
+ }
+
+ try
+ {
+ JsonNode.Parse(File.ReadAllText(filePath));
+ }
+ catch (Exception parseEx)
+ {
+ Log.Error($"Settings file '{filePath}' is corrupt; moving it aside and starting with defaults", parseEx);
+ try
+ {
+ File.Move(filePath, filePath + ".bad", overwrite: true);
+ }
+ catch (Exception moveEx)
+ {
+ Log.Warn($"Couldn't quarantine corrupt settings file: {moveEx.Message}");
+ }
+ }
+ }
}
diff --git a/PaletteShellExtension/Classes/PinnedScripts.cs b/PaletteShellExtension/Classes/PinnedScripts.cs
index d55cc2a..5b92508 100644
--- a/PaletteShellExtension/Classes/PinnedScripts.cs
+++ b/PaletteShellExtension/Classes/PinnedScripts.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -26,6 +27,12 @@ internal sealed class PinnedScripts
private readonly string _storePath;
private readonly HashSet _pinned;
+ // Memoizes path → store key: IsPinned runs once or twice per script per list rebuild
+ // (including from GetItems' parallel loop, hence concurrent), and the relative-path
+ // computation is the only non-trivial part. Entries are never invalidated — a path's
+ // key is a pure function of it and this instance's fixed root.
+ private readonly ConcurrentDictionary _keyCache = new(StringComparer.OrdinalIgnoreCase);
+
public PinnedScripts(string rootDirectory)
{
_rootDirectory = rootDirectory;
@@ -53,9 +60,11 @@ public bool Toggle(string path)
// Path relative to the scripts root, normalized to forward slashes so the stored key
// doesn't depend on the platform separator (e.g. "Git/Sync.ps1").
private string KeyFor(string path) =>
- Path.GetRelativePath(_rootDirectory, path)
- .Replace(Path.DirectorySeparatorChar, '/')
- .Replace(Path.AltDirectorySeparatorChar, '/');
+ _keyCache.GetOrAdd(path, static (p, root) =>
+ Path.GetRelativePath(root, p)
+ .Replace(Path.DirectorySeparatorChar, '/')
+ .Replace(Path.AltDirectorySeparatorChar, '/'),
+ _rootDirectory);
private static HashSet Load(string storePath)
{
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/SampleScriptInstaller.cs b/PaletteShellExtension/Classes/SampleScriptInstaller.cs
new file mode 100644
index 0000000..6375f57
--- /dev/null
+++ b/PaletteShellExtension/Classes/SampleScriptInstaller.cs
@@ -0,0 +1,174 @@
+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;
+
+/// 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 ContentHash);
+
+///
+/// Tracks the hash of each bundled sample script this process has written into the scripts
+/// folder, plus the app version that last completed a full sample sync so an unchanged
+/// install can skip the sync entirely. 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 static readonly JsonSerializerOptions SaveOptions = new() { WriteIndented = true };
+
+ private readonly string _storePath;
+ private readonly Dictionary _installed;
+
+ /// The app version whose bundled samples were last fully synced into this folder,
+ /// or null when no sync has completed (fresh folder, legacy store, or corrupt store).
+ /// Shipped sample content can only change with the app version, so a match means the whole
+ /// sync pass can be skipped.
+ public string? SyncedAppVersion { get; private set; }
+
+ public InstalledSampleScripts(string rootDirectory)
+ {
+ _storePath = Path.Combine(rootDirectory, StoreFileName);
+ (_installed, SyncedAppVersion) = Load(_storePath);
+ }
+
+ /// Stamps the store with the app version whose sync pass just completed cleanly
+ /// and persists everything recorded so far.
+ public void MarkSynced(string appVersion)
+ {
+ SyncedAppVersion = appVersion;
+ Save();
+ }
+
+ public bool TryGet(string fileName, out InstalledSampleScript? record) =>
+ _installed.TryGetValue(fileName, out record);
+
+ /// Records (or updates) the sample's installed hash and persists the change.
+ public void Record(string fileName, string contentHash)
+ {
+ Record(fileName, contentHash, persist: true);
+ }
+
+ internal void Record(string fileName, string contentHash, bool persist)
+ {
+ _installed[fileName] = new InstalledSampleScript(contentHash);
+ if (persist)
+ {
+ Save();
+ }
+ }
+
+ internal void Save() => SaveCore();
+
+ private static (Dictionary Installed, string? SyncedAppVersion) Load(string storePath)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ if (File.Exists(storePath))
+ {
+ var root = JsonNode.Parse(File.ReadAllText(storePath))?.AsObject();
+ if (root is not null)
+ {
+ // Current format nests the per-script records under "scripts" with the
+ // version stamp alongside; the legacy format was the flat map itself.
+ // Sample file names always end in ".ps1", so "scripts" can't be a record key.
+ var scripts = root["scripts"] as JsonObject ?? root;
+ var version = ReferenceEquals(scripts, root) ? null : root["appVersion"]?.ToString();
+
+ foreach (var (key, value) in scripts)
+ {
+ var obj = value as JsonObject;
+ var hash = obj?["hash"]?.ToString();
+ if (hash is not null)
+ {
+ map[key] = new InstalledSampleScript(hash);
+ }
+ }
+
+ return (map, version);
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Missing or corrupt store - start with nothing tracked rather than failing startup.
+ map.Clear();
+ }
+
+ return (map, null);
+ }
+
+ private void SaveCore()
+ {
+ try
+ {
+ var scripts = new JsonObject();
+ foreach (var (key, value) in _installed.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ scripts[key] = new JsonObject { ["hash"] = value.ContentHash };
+ }
+
+ var root = new JsonObject { ["scripts"] = scripts };
+ if (SyncedAppVersion is not null)
+ {
+ root["appVersion"] = SyncedAppVersion;
+ }
+
+ File.WriteAllText(_storePath, root.ToJsonString(SaveOptions));
+ }
+ 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 content actually differs from
+/// what was last installed.
+///
+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 shippedHash)
+ {
+ if (!targetExists)
+ {
+ return record is null;
+ }
+
+ if (record is null)
+ {
+ return false;
+ }
+
+ if (!string.Equals(onDiskHash, record.ContentHash, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return !string.Equals(shippedHash, record.ContentHash, StringComparison.Ordinal);
+ }
+}
diff --git a/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs b/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs
new file mode 100644
index 0000000..e72f9bb
--- /dev/null
+++ b/PaletteShellExtension/Classes/ScriptArgumentBuilder.cs
@@ -0,0 +1,67 @@
+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();
+
+ // 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;
+
+ 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..ea17f5d
--- /dev/null
+++ b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs
@@ -0,0 +1,72 @@
+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 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,
+
+ /// 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,
+}
+
+///
+/// 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,
+ string? BadHost = null)
+{
+ private static readonly ScriptCompatibility Compatible = new(ScriptCompatibilityKind.Ok);
+
+ /// 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;
+
+ 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);
+
+ // 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);
+
+ return Compatible;
+ }
+}
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/ScriptExecutionPlan.cs b/PaletteShellExtension/Classes/ScriptExecutionPlan.cs
new file mode 100644
index 0000000..62d5f6b
--- /dev/null
+++ b/PaletteShellExtension/Classes/ScriptExecutionPlan.cs
@@ -0,0 +1,65 @@
+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);
+
+ /// 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/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/Classes/ScriptFailureReport.cs b/PaletteShellExtension/Classes/ScriptFailureReport.cs
new file mode 100644
index 0000000..3111ab8
--- /dev/null
+++ b/PaletteShellExtension/Classes/ScriptFailureReport.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Text.RegularExpressions;
+
+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
+{
+ // 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)
+ {
+ if (result is null)
+ return "failed to start";
+
+ if (result.TimedOut)
+ return "timed out and was killed";
+
+ return $"exited with code {result.ExitCode}";
+ }
+
+ /// One-line outcome for failures that happen outside the completed script process:
+ /// launcher exceptions, runner exceptions, or output side-effect failures.
+ public static string DescribeOutcome(Exception exception) =>
+ $"failed: {exception.Message}";
+
+ ///
+ /// 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.DescribeShell(host)}");
+ sb.AppendLine(culture, $"Args: {(string.IsNullOrWhiteSpace(args) ? "(none)" : RedactArgs(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}");
+ sb.AppendLine(culture, $"This report: {EditorLauncher.OutputDirectory} (auto-cleaned when enabled in settings)");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Full failure report for failures that happen before or after a script result exists, such as
+ /// process-launch exceptions or output-mode side-effect failures.
+ ///
+ public static string Build(string scriptPath, string host, string args, Exception exception)
+ {
+ 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.DescribeShell(host)}");
+ sb.AppendLine(culture, $"Args: {(string.IsNullOrWhiteSpace(args) ? "(none)" : RedactArgs(args))}");
+ sb.AppendLine(culture, $"Outcome: {DescribeOutcome(exception)}");
+ sb.AppendLine();
+ sb.AppendLine("----- exception -----");
+ sb.AppendLine(exception.ToString());
+ sb.AppendLine();
+ sb.AppendLine(culture, $"Logs: {Log.LogDirectory}");
+ sb.AppendLine(culture, $"This report: {EditorLauncher.OutputDirectory} (auto-cleaned when enabled in settings)");
+
+ 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/ScriptManifest.cs b/PaletteShellExtension/Classes/ScriptManifest.cs
index f20b4f6..c306f57 100644
--- a/PaletteShellExtension/Classes/ScriptManifest.cs
+++ b/PaletteShellExtension/Classes/ScriptManifest.cs
@@ -10,8 +10,32 @@ public sealed class ScriptManifest
public List Parameters { get; set; } = [];
public string? Group { get; set; }
+ 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
+ // 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; }
+
+ // 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/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs
index b252ed2..373aec6 100644
--- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs
+++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs
@@ -1,58 +1,83 @@
using Microsoft.CommandPalette.Extensions.Toolkit;
using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
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)
+ internal static CommandResult OpenTarget(string target)
{
- switch (mode?.Trim().ToLowerInvariant())
- {
- case "clipboard":
- if (!string.IsNullOrEmpty(output))
- {
- TrySetClipboard(output);
- return CommandResult.ShowToast("Copied to clipboard");
- }
- return CommandResult.ShowToast("Script completed");
-
- // 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");
+ 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))
+ {
+ 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)
+ internal static void TrySetClipboard(string text)
{
try
{
- TextCopy.ClipboardService.SetText(text ?? "");
+ ClipboardHelper.SetText(text);
}
catch (Exception ex)
{
Log.Warn($"Failed to set clipboard text: {ex.Message}");
+ throw;
}
}
}
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..e2b6e47
--- /dev/null
+++ b/PaletteShellExtension/Classes/ScriptRunDispatcher.cs
@@ -0,0 +1,89 @@
+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 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
+/// 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 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)
+ ? new RunOutcome("Script completed", null, null)
+ : new RunOutcome(value!, value, null);
+ }
+ }
+}
diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs
index be3bce5..637f9d9 100644
--- a/PaletteShellExtension/Classes/ScriptRunner.cs
+++ b/PaletteShellExtension/Classes/ScriptRunner.cs
@@ -3,11 +3,13 @@
using System.Diagnostics;
using System.IO;
using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
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 +19,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 +45,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 +70,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)
{
@@ -57,44 +78,301 @@ public static string DescribeFailure(ScriptResult result)
}
}
- private static readonly Lazy PwshAvailable = new(() => CanResolveOnPath("pwsh.exe"));
+ /// Async counterpart of — same brief grace period,
+ /// but without pinning a thread while it waits.
+ private static async Task AwaitReadAsync(Task? task)
+ {
+ if (task is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return StripAnsi(await task.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false));
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// 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))
+ {
+ 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();
+ }
+
+ string throwUnknown() =>
+ throw new ShellResolutionException($"Unknown script host '{host}'. Supported values are 'auto', 'pwsh', and 'powershell'.");
+ }
+
+ /// 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());
+
+ // 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)
{
- return "powershell.exe";
+ _pwshInstalled = FindPwsh() is not null;
+ _pwshProbed = true;
}
- // 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";
+ return _pwshInstalled;
}
- private static bool CanResolveOnPath(string exe)
+ /// 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());
+
+ /// 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 path.Split(Path.PathSeparator))
+ foreach (var dir in extraDirs)
{
+ if (Contains(dir, exe, out var hit))
+ {
+ return hit;
+ }
+ }
+
+ 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;
+ ///
+ /// 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(PowerShellQuoting.SingleQuote(name));
+ }
+
+ if (quoted.Count == 0)
+ {
+ return "";
+ }
+
+ 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)) { " +
+ "[Console]::Error.WriteLine(\"Missing required module '$__psRequired'. Install it with: Install-Module -Name $__psRequired -Scope CurrentUser\"); " +
+ "exit 1 } }; ";
}
public static ProcessStartInfo BuildProcessStartInfo(
@@ -104,7 +382,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);
@@ -118,16 +397,21 @@ 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
+ // 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; . {PowerShellQuoting.SingleQuote(scriptPath)} {args} 6>&1";
psi.ArgumentList.Add(commandString);
if (!string.IsNullOrWhiteSpace(cwd))
@@ -173,11 +457,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);
@@ -190,6 +476,130 @@ public static bool RunScript(
}
}
+ ///
+ /// Async counterpart of for callers that already run off
+ /// the UI path (the List/Markdown/Result pages): the wait for the child process is
+ /// awaited rather than blocked on, so a slow script doesn't pin a threadpool thread for
+ /// its whole run (up to the timeout) per in-flight query. The synchronous version stays
+ /// for the toolkit's Invoke() entry points, which are inherently blocking.
+ ///
+ public static async Task RunScriptAndWaitAsync(
+ string scriptPath,
+ string args,
+ string host,
+ string? cwd,
+ Dictionary? env = null,
+ bool requiresAdmin = false,
+ int? timeoutMs = null,
+ bool reportProgress = true,
+ 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
+ ? ScriptStatus.ShowRunning(Path.GetFileNameWithoutExtension(scriptPath))
+ : null;
+
+ try
+ {
+ var psi = BuildProcessStartInfo(
+ scriptPath: scriptPath,
+ args: args,
+ host: host,
+ cwd: cwd,
+ env: env,
+ requiresAdmin: requiresAdmin,
+ captureOutput: !requiresAdmin,
+ requiredModules: requiredModules);
+
+ proc = Process.Start(psi);
+
+ if (proc == null)
+ {
+ return null;
+ }
+
+ var result = new ScriptResult();
+
+ // Same deadlock/timeout reasoning as the synchronous version: kick off both
+ // stream reads before waiting so a full pipe buffer can't wedge the child and
+ // a hung child can't defeat the timeout.
+ Task? stdoutTask = null;
+ Task? stderrTask = null;
+ if (!requiresAdmin)
+ {
+ stdoutTask = proc.StandardOutput.ReadToEndAsync();
+ stderrTask = proc.StandardError.ReadToEndAsync();
+ }
+
+ try
+ {
+ 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)
+ {
+ // 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;
+ }
+ catch (OperationCanceledException)
+ {
+ // 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
+ // promptly; this also ensures async I/O completion before we read ExitCode.
+ 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}{ScriptFailureReport.StderrForLog(result.StandardError)}");
+ }
+ return result;
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"Failed to run script '{scriptPath}'", ex);
+ return null;
+ }
+ finally
+ {
+ proc?.Dispose();
+ ScriptStatus.Hide(progress);
+ }
+ }
+
public static ScriptResult? RunScriptAndWait(
string scriptPath,
string args,
@@ -198,15 +608,30 @@ 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;
+ var scriptName = Path.GetFileNameWithoutExtension(scriptPath);
+
+ // This wait happens on a thread the host is synchronously blocked on (a COM call),
+ // so it must always be bounded: a null or oversized timeout gets the same hard
+ // ceiling a script-declared [ScriptTimeout(...)] does. No code path waits forever.
+ var effectiveTimeoutMs = Math.Min(
+ timeoutMs ?? PowerShellScriptParser.MaxTimeoutMs,
+ PowerShellScriptParser.MaxTimeoutMs);
+
+ // One line at start and one at finish, so a hang or crash report can be lined up
+ // against the log: a start line with no matching finish points at the culprit.
+ Log.Info($"Running '{scriptName}' (host {host}, timeout {effectiveTimeoutMs}ms)");
+ var stopwatch = Stopwatch.StartNew();
+
// Show a "Running