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