diff --git a/.github/workflows/publish-module.yml b/.github/workflows/publish-module.yml index 9f60f21..d8d70fc 100644 --- a/.github/workflows/publish-module.yml +++ b/.github/workflows/publish-module.yml @@ -15,6 +15,7 @@ on: - Shmuelie.Copilot - Shmuelie.Node - Shmuelie.Utilities + - Shmuelie.Dsc jobs: publish: @@ -38,11 +39,11 @@ jobs: INPUT_MODULE: ${{ inputs.module }} REF_NAME: ${{ github.ref_name }} run: | - $allowed = @('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities') + $allowed = @('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities', 'Shmuelie.Dsc') $module = $env:INPUT_MODULE $tagVersion = $null if (-not $module) { - if ($env:REF_NAME -notmatch '^(?Shmuelie\.(?:Git|Copilot|Node|Utilities))-v(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$') { + if ($env:REF_NAME -notmatch '^(?Shmuelie\.(?:Git|Copilot|Node|Utilities|Dsc))-v(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$') { throw "Invalid release tag: $env:REF_NAME" } $module = $Matches.module diff --git a/CHANGELOG.md b/CHANGELOG.md index c651636..71f7021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,14 @@ between releases this file tracks catalog-level changes under `[Unreleased]`. site. - `Build-Module.ps1` now stages optional module `Classes` folders for shared PowerShell class definitions. +- `Build-Module.ps1` now stages a module's `Public` folder only when it exists, + so modules without `Public` scripts (such as DSC resource modules) build; it + also fails fast if a module declares functions but has no `Public` folder. ### Added +- New `Shmuelie.Dsc` module: class-based DSC v3 resources (`SavePSResource`, + `SymbolicLink`, `CopilotPlugin`, `CopilotMarketplace`, `UvTool`), wired into + the build, publish workflow, and documentation site. - GitHub Pages documentation workflow now redeploys automatically when docs or its workflow change. - Pester v5 unit test suite under `tests/`, a `build/Invoke-Tests.ps1` runner, and CI wiring (`ci.yml` plus a gate in `publish-module.yml`) so behavioral diff --git a/README.md b/README.md index 6add825..7740e04 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Install-PSResource Shmuelie.Git Install-PSResource Shmuelie.Copilot Install-PSResource Shmuelie.Node Install-PSResource Shmuelie.Utilities +Install-PSResource Shmuelie.Dsc ``` ## Modules @@ -18,6 +19,7 @@ Install-PSResource Shmuelie.Utilities | [Shmuelie.Copilot](docs/modules.md#shmuelie-copilot) | Copilot CLI sessions, plugins, marketplaces, MCP, and launcher | | [Shmuelie.Node](docs/modules.md#shmuelie-node) | Node.js, nvm-windows, npm, and ADO npm credentials | | [Shmuelie.Utilities](docs/modules.md#shmuelie-utilities) | .NET, Python, VS Code, Terminal, services, and WPR | +| [Shmuelie.Dsc](docs/modules.md#shmuelie-dsc) | DSC v3 resources for setup: modules, symlinks, Copilot plugins/marketplaces, uv tools | ## Documentation diff --git a/build/Build-Module.ps1 b/build/Build-Module.ps1 index 2ae78d8..62d2cd7 100644 --- a/build/Build-Module.ps1 +++ b/build/Build-Module.ps1 @@ -9,7 +9,7 @@ [CmdletBinding()] param( [Parameter(Mandatory)] - [ValidateSet('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities')] + [ValidateSet('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities', 'Shmuelie.Dsc')] [string]$Module, [string]$OutputPath @@ -34,7 +34,13 @@ New-Item $stage -ItemType Directory -Force | Out-Null foreach ($name in @("$Module.psd1", "$Module.psm1", 'README.md', 'CHANGELOG.md')) { Copy-Item (Join-Path $source $name) $stage } -Copy-Item (Join-Path $source 'Public') $stage -Recurse +$public = Join-Path $source 'Public' +if (Test-Path $public) { + Copy-Item $public $stage -Recurse +} +if ($manifest.ExportedFunctions.Count -gt 0 -and -not (Test-Path $public)) { + throw "$Module declares FunctionsToExport but has no Public folder to stage." +} $classes = Join-Path $source 'Classes' if (Test-Path $classes) { Copy-Item $classes $stage -Recurse diff --git a/build/Publish-Module.ps1 b/build/Publish-Module.ps1 index 30f20fc..b9631f0 100644 --- a/build/Publish-Module.ps1 +++ b/build/Publish-Module.ps1 @@ -1,7 +1,7 @@ [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] - [ValidateSet('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities')] + [ValidateSet('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities', 'Shmuelie.Dsc')] [string]$Module, [Parameter(Mandatory)] diff --git a/build/Test-Modules.ps1 b/build/Test-Modules.ps1 index 161ebe2..7eb7f1c 100644 --- a/build/Test-Modules.ps1 +++ b/build/Test-Modules.ps1 @@ -3,7 +3,7 @@ param() $ErrorActionPreference = 'Stop' $repoRoot = Split-Path $PSScriptRoot -Parent -$modules = @('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities') +$modules = @('Shmuelie.Git', 'Shmuelie.Copilot', 'Shmuelie.Node', 'Shmuelie.Utilities', 'Shmuelie.Dsc') foreach ($module in $modules) { Write-Information "Building $module" -InformationAction Continue diff --git a/docs/index.md b/docs/index.md index 64d9c6d..dfaf6a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,6 +19,7 @@ install and upgrade each module on its own. | [Shmuelie.Copilot](modules.md#shmuelie-copilot) | Copilot CLI sessions, plugins, marketplaces, MCP, and launcher | 0.2.0 | | [Shmuelie.Node](modules.md#shmuelie-node) | Node.js, nvm-windows, npm, and ADO npm credentials | 0.1.3 | | [Shmuelie.Utilities](modules.md#shmuelie-utilities) | .NET, Python, VS Code, Terminal, services, and WPR | 0.2.3 | +| [Shmuelie.Dsc](modules.md#shmuelie-dsc) | DSC v3 resources for setup: modules, symlinks, Copilot plugins/marketplaces, uv tools | 0.1.0 | ## Quick start diff --git a/docs/modules.md b/docs/modules.md index 552dcd7..d12dbf2 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -85,3 +85,17 @@ Highlights: - Core helpers: `Test-IsElevated`, `Invoke-InLocation`, `Reset-TerminalModes`. - Tool management for `dotnet`, `pip`, `uv`, and VS Code extensions. - `Get-ServiceProcess` resolves a service to its hosting process. + +## Shmuelie.Dsc + +Class-based [DSC v3](https://learn.microsoft.com/powershell/dsc/overview) +resources for developer machine setup. **Version 0.1.0.** +[README](https://github.com/shmuelie/powershell-modules/blob/main/modules/Shmuelie.Dsc/README.md) + +Highlights: + +- `SavePSResource` saves a PowerShell module to a local path; `SymbolicLink` + creates and verifies symbolic links. +- `CopilotPlugin` and `CopilotMarketplace` install GitHub Copilot CLI plugins + and register marketplaces. +- `UvTool` installs a Python tool via `uv tool install`. diff --git a/modules/Shmuelie.Dsc/CHANGELOG.md b/modules/Shmuelie.Dsc/CHANGELOG.md new file mode 100644 index 0000000..ec19f61 --- /dev/null +++ b/modules/Shmuelie.Dsc/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/). +Versions change only when a release is cut; unreleased work stays under +`[Unreleased]`. + +## [Unreleased] + +### Added +- Initial class-based DSC v3 resources: `SavePSResource` (save a module to a + local path, with an optional `Version`), `SymbolicLink` (create/verify a + symbolic link), `CopilotPlugin` (install a GitHub Copilot CLI plugin, with an + optional `Name` for URL sources), `CopilotMarketplace` (register a Copilot CLI + marketplace), and `UvTool` (install a Python tool via `uv`). Presence checks + use whole-token matching over ANSI-stripped CLI output, CLI arguments are + validated as shell-safe, `Get()` reports actual state, and CLI failures + include the tool's output. diff --git a/modules/Shmuelie.Dsc/README.md b/modules/Shmuelie.Dsc/README.md new file mode 100644 index 0000000..5d220cb --- /dev/null +++ b/modules/Shmuelie.Dsc/README.md @@ -0,0 +1,88 @@ +# Shmuelie.Dsc + +Class-based [DSC v3](https://learn.microsoft.com/powershell/dsc/overview) +resources for developer machine setup. Each resource is a PowerShell class +implementing `Get()`, `Test()`, and `Set()`, exported via +`DscResourcesToExport`. The Copilot and uv resources depend only on the public +`copilot` and `uv` CLIs; the others use built-in PowerShell only. + +**Version:** 0.1.0 + +## Install + +```powershell +Install-PSResource Shmuelie.Dsc +``` + +## Resources + +| Resource | Key | Purpose | +|---|---|---| +| `SavePSResource` | `Name` | Save a PowerShell module to a local path via `Save-PSResource` (defaults to `PSGallery`). | +| `SymbolicLink` | `Path` | Create/verify a symbolic link to a target path. | +| `CopilotPlugin` | `Source` | Install a GitHub Copilot CLI plugin (`owner/repo`, `plugin@marketplace`, or URL). | +| `CopilotMarketplace` | `Name` | Register a GitHub Copilot CLI plugin marketplace (`owner/repo`). | +| `UvTool` | `Name` | Install a Python tool via `uv tool install`. | + +## Usage + +These are DSC v3 resources, addressed as `Shmuelie.Dsc/`: + +```yaml +- name: Save Pester + type: Shmuelie.Dsc/SavePSResource + properties: + Name: Pester + Path: C:\Modules + +- name: Symlink .gitconfig + type: Shmuelie.Dsc/SymbolicLink + properties: + Path: C:\Users\me\.gitconfig + Target: C:\dotfiles\.gitconfig + +- name: Install a Copilot plugin + type: Shmuelie.Dsc/CopilotPlugin + properties: + Source: owner/repo + +- name: Register a Copilot marketplace + type: Shmuelie.Dsc/CopilotMarketplace + properties: + Name: dotnet-skills + Repository: dotnet/skills + +- name: Install a uv tool + type: Shmuelie.Dsc/UvTool + properties: + Name: fast-agent-mcp +``` + +## Notes + +- **Idempotency / presence checks.** `CopilotPlugin`, `CopilotMarketplace`, and + `UvTool` determine "already installed" by a whole-token match against the + relevant CLI list output (color/ANSI is stripped first), so a desired name + that is a substring of another entry does not produce a false positive. +- **`CopilotPlugin` URL sources.** The installed plugin name is derived from + `Source` for `owner/repo`, `plugin@marketplace`, and `market:plugin@marketplace` + forms. For a URL source the name cannot be derived reliably — set the optional + `Name` property so the presence check matches, otherwise the plugin is + re-installed on every apply. +- **`SavePSResource` version.** Set the optional `Version` property to make the + presence check (and the save) version-specific. +- **Shell-safe arguments.** Values passed to the `copilot`/`uv` CLIs are + validated to reject characters that Windows would re-parse when the CLI + resolves to a `.cmd`/`.bat` shim. + +## Requirements + +- PowerShell 7.4 or later. +- `SavePSResource` requires `Microsoft.PowerShell.PSResourceGet` (`Save-PSResource`). +- `CopilotPlugin` / `CopilotMarketplace` require the GitHub Copilot CLI (`copilot`) on `PATH`. +- `UvTool` requires the `uv` CLI on `PATH`. +- `SymbolicLink` on Windows requires Developer Mode or an elevated session. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). diff --git a/modules/Shmuelie.Dsc/Shmuelie.Dsc.psd1 b/modules/Shmuelie.Dsc/Shmuelie.Dsc.psd1 new file mode 100644 index 0000000..af1dd89 --- /dev/null +++ b/modules/Shmuelie.Dsc/Shmuelie.Dsc.psd1 @@ -0,0 +1,23 @@ +@{ + RootModule = 'Shmuelie.Dsc.psm1' + ModuleVersion = '0.1.0' + GUID = 'd4406e2b-fd8f-4311-aa4e-178d2794102a' + Author = 'Shmueli Englard' + CompanyName = 'Shmuelie' + Copyright = '(c) Shmueli Englard. All rights reserved.' + Description = 'Class-based DSC v3 resources for developer machine setup: save PowerShell modules to a path, manage symbolic links, install GitHub Copilot CLI plugins and marketplaces, and install uv Python tools.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('SavePSResource', 'SymbolicLink', 'CopilotPlugin', 'CopilotMarketplace', 'UvTool') + PrivateData = @{ + PSData = @{ + Tags = @('DSC', 'DSCv3', 'DesiredStateConfiguration', 'PSDscResource', 'Setup', 'DeveloperTools') + LicenseUri = 'https://github.com/shmuelie/powershell-modules/blob/main/LICENSE' + ProjectUri = 'https://github.com/shmuelie/powershell-modules' + } + } +} diff --git a/modules/Shmuelie.Dsc/Shmuelie.Dsc.psm1 b/modules/Shmuelie.Dsc/Shmuelie.Dsc.psm1 new file mode 100644 index 0000000..5c1592e --- /dev/null +++ b/modules/Shmuelie.Dsc/Shmuelie.Dsc.psm1 @@ -0,0 +1,478 @@ +# Private helpers ------------------------------------------------------------- +# These support the resource classes and are intentionally not exported +# (FunctionsToExport is empty in the manifest). The CLI wrappers exist so the +# resource classes can be unit-tested by mocking them. + +function Remove-DscAnsiEscape { + # Strip ANSI/VT escape sequences so colorized CLI output does not defeat the + # presence checks (e.g. uv colorizes `uv tool list` by default). + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Text + ) + + return ($Text -replace "$([char]27)\[[0-9;]*[A-Za-z]", '') +} + +function Test-DscListContainsToken { + # Whole-token (whitespace-delimited) membership test against CLI list output. + # Using exact token equality avoids the substring false positives a bare + # `-match` would produce (e.g. 'mcp' matching 'fast-agent-mcp'). + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$Lines, + + [Parameter(Mandatory)] + [string]$Token + ) + + foreach ($line in $Lines) { + $tokens = $line -split '\s+' | Where-Object { $_ -ne '' } + if ($tokens -contains $Token) { + return $true + } + } + return $false +} + +function Assert-DscSafeArgument { + # Reject values containing characters that cmd.exe would re-parse when a CLI + # resolves to a .cmd/.bat shim on Windows (BatBadBut / CVE-2024-1874 class). + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Value, + + [Parameter(Mandatory)] + [string]$Name + ) + + if ($Value -match '[&|<>^"%!()\x60\r\n]') { + throw "$Name contains characters that are not allowed for a shell-safe argument: '$Value'" + } +} + +function Invoke-DscCopilot { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Arguments + ) + + $previousNoColor = $env:NO_COLOR + $env:NO_COLOR = '1' + try { + $raw = & copilot @Arguments 2>&1 + $exit = $LASTEXITCODE + } finally { + if ($null -eq $previousNoColor) { + Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue + } else { + $env:NO_COLOR = $previousNoColor + } + } + $lines = @($raw | ForEach-Object { Remove-DscAnsiEscape ([string]$_) }) + [pscustomobject]@{ + Output = $lines + ExitCode = $exit + } +} + +function Invoke-DscUv { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Arguments + ) + + $previousNoColor = $env:NO_COLOR + $previousUvNoColor = $env:UV_NO_COLOR + $env:NO_COLOR = '1' + $env:UV_NO_COLOR = '1' + try { + $raw = & uv @Arguments 2>&1 + $exit = $LASTEXITCODE + } finally { + if ($null -eq $previousNoColor) { + Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue + } else { + $env:NO_COLOR = $previousNoColor + } + if ($null -eq $previousUvNoColor) { + Remove-Item Env:UV_NO_COLOR -ErrorAction SilentlyContinue + } else { + $env:UV_NO_COLOR = $previousUvNoColor + } + } + $lines = @($raw | ForEach-Object { Remove-DscAnsiEscape ([string]$_) }) + [pscustomobject]@{ + Output = $lines + ExitCode = $exit + } +} + +function New-DscSymbolicLink { + # Wraps New-Item's symbolic-link creation. The -Target parameter is a dynamic + # provider parameter, so isolating it here keeps the SymbolicLink resource + # unit-testable (tests mock this function). + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string]$Target + ) + + New-Item -ItemType SymbolicLink -Path $Path -Target $Target -Force -ErrorAction Stop | Out-Null +} + +# Resources ------------------------------------------------------------------- + +<# +.SYNOPSIS + Saves a PowerShell module to a local path using Save-PSResource. + +.DESCRIPTION + DSC resource that ensures a PowerShell module is saved (not installed) to a + specified directory. Tests for existence by checking whether a subfolder + matching the module name (and version, when specified) exists under Path. + Uses Save-PSResource with -TrustRepository, -IncludeXml, -AcceptLicense, and + -SkipDependencyCheck so it runs non-interactively. + + Depends only on the public Microsoft.PowerShell.PSResourceGet module (which + ships with PowerShell 7.4+). + +.PROPERTY Name + The name of the PowerShell module to save. This is the key property. + +.PROPERTY Path + The directory to save the module into (e.g. a local modules directory). + +.PROPERTY Repository + The PSResourceRepository to save from. Defaults to 'PSGallery'. + +.PROPERTY Version + Optional specific version to save. When set, Test() checks for that version's + subfolder and Set() passes it to Save-PSResource -Version. + +.PROPERTY Installed + Read-only. Reports whether the module (and version, if specified) is present. + +.EXAMPLE + - name: Save Pester + type: Shmuelie.Dsc/SavePSResource + properties: + Name: Pester + Path: C:\Modules +#> +[DscResource()] +class SavePSResource { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $Path + + [DscProperty()] + [string] $Repository = 'PSGallery' + + [DscProperty()] + [string] $Version = '' + + [DscProperty(NotConfigurable)] + [bool] $Installed + + [SavePSResource] Get() { + $state = [SavePSResource]@{ + Name = $this.Name + Path = $this.Path + Repository = $this.Repository + Version = $this.Version + } + $state.Installed = $this.Test() + return $state + } + + [bool] Test() { + $modulePath = Join-Path $this.Path $this.Name + if (-not (Test-Path -LiteralPath $modulePath)) { + return $false + } + if ($this.Version) { + return (Test-Path -LiteralPath (Join-Path $modulePath $this.Version)) + } + return $true + } + + [void] Set() { + $params = @{ + Name = $this.Name + Path = $this.Path + Repository = $this.Repository + TrustRepository = $true + IncludeXml = $true + AcceptLicense = $true + SkipDependencyCheck = $true + } + if ($this.Version) { + $params['Version'] = $this.Version + } + Save-PSResource @params + } +} + +<# +.SYNOPSIS + Creates or verifies a symbolic link at a specified path. + +.DESCRIPTION + DSC resource that ensures a symbolic link exists pointing to the correct + target. Creates parent directories if they do not exist and replaces an + existing item at Path when the link is missing or points elsewhere. + + On Windows, creating symbolic links requires Developer Mode or an elevated + session. + +.PROPERTY Path + The full path where the symbolic link should exist. This is the key property. + +.PROPERTY Target + The target path the symbolic link should point to. Get() reports the actual + current target (empty when Path is absent or is not a symbolic link). + +.EXAMPLE + - name: Symlink .gitconfig + type: Shmuelie.Dsc/SymbolicLink + properties: + Path: C:\Users\me\.gitconfig + Target: C:\dotfiles\.gitconfig +#> +[DscResource()] +class SymbolicLink { + [DscProperty(Key)] + [string] $Path + + [DscProperty(Mandatory)] + [string] $Target + + [SymbolicLink] Get() { + $item = Get-Item -LiteralPath $this.Path -ErrorAction SilentlyContinue + $current = if ($item -and $item.LinkType -eq 'SymbolicLink') { [string]$item.Target } else { '' } + return [SymbolicLink]@{ + Path = $this.Path + Target = $current + } + } + + [bool] Test() { + $item = Get-Item -LiteralPath $this.Path -ErrorAction SilentlyContinue + if ($null -eq $item -or $item.LinkType -ne 'SymbolicLink') { + return $false + } + return ([string]$item.Target -eq $this.Target) + } + + [void] Set() { + $parentDir = Split-Path -Path $this.Path -Parent + if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) { + New-Item -ItemType Directory -Path $parentDir -Force | Out-Null + } + New-DscSymbolicLink -Path $this.Path -Target $this.Target + } +} + +<# +.SYNOPSIS + Installs a GitHub Copilot CLI plugin. + +.DESCRIPTION + DSC resource that ensures a Copilot CLI plugin is installed. Tests by + checking whether the plugin's name appears as a whole token in + 'copilot plugin list' output. Supports the owner/repo, plugin@marketplace, + and market:plugin@marketplace source formats accepted by the Copilot CLI. + + For a URL source (or any source whose installed plugin name cannot be + derived from the source spec), set the Name property so Test() can match the + installed plugin; otherwise the plugin is re-installed on every apply. + + Depends only on the public GitHub Copilot CLI (copilot) on PATH. + +.PROPERTY Source + The plugin source to install (owner/repo, plugin@marketplace, + market:plugin@marketplace, or a URL). This is the key property. + +.PROPERTY Name + Optional. The installed plugin name used for the presence check. Defaults to + the name derived from Source. Set this for URL sources. + +.PROPERTY Installed + Read-only. Reports whether the plugin is installed. + +.EXAMPLE + - name: Install a plugin + type: Shmuelie.Dsc/CopilotPlugin + properties: + Source: owner/repo +#> +[DscResource()] +class CopilotPlugin { + [DscProperty(Key)] + [string] $Source + + [DscProperty()] + [string] $Name = '' + + [DscProperty(NotConfigurable)] + [bool] $Installed + + hidden [string] ResolveName() { + if ($this.Name) { + return $this.Name + } + $spec = (($this.Source -split '@')[0]) -replace '^market:', '' + return ($spec -split '/' | Where-Object { $_ -ne '' } | Select-Object -Last 1) + } + + [CopilotPlugin] Get() { + $state = [CopilotPlugin]@{ + Source = $this.Source + Name = $this.Name + } + $state.Installed = $this.Test() + return $state + } + + [bool] Test() { + $result = Invoke-DscCopilot -Arguments @('plugin', 'list') + return Test-DscListContainsToken -Lines $result.Output -Token $this.ResolveName() + } + + [void] Set() { + Assert-DscSafeArgument -Value $this.Source -Name 'Source' + $result = Invoke-DscCopilot -Arguments @('plugin', 'install', $this.Source) + if ($result.ExitCode -ne 0) { + throw "Failed to install Copilot plugin '$($this.Source)': $($result.Output -join '; ')" + } + } +} + +<# +.SYNOPSIS + Registers a GitHub Copilot CLI plugin marketplace. + +.DESCRIPTION + DSC resource that ensures a Copilot CLI plugin marketplace is registered. + Tests by checking whether the marketplace name appears as a whole token in + 'copilot plugin marketplace list' output. + + Depends only on the public GitHub Copilot CLI (copilot) on PATH. + +.PROPERTY Name + The name to register the marketplace under. This is the key property. + +.PROPERTY Repository + The GitHub repository hosting the marketplace (owner/repo format). + +.PROPERTY Installed + Read-only. Reports whether the marketplace is registered. + +.EXAMPLE + - name: Register a marketplace + type: Shmuelie.Dsc/CopilotMarketplace + properties: + Name: dotnet-skills + Repository: dotnet/skills +#> +[DscResource()] +class CopilotMarketplace { + [DscProperty(Key)] + [string] $Name + + [DscProperty(Mandatory)] + [string] $Repository + + [DscProperty(NotConfigurable)] + [bool] $Installed + + [CopilotMarketplace] Get() { + $state = [CopilotMarketplace]@{ + Name = $this.Name + Repository = $this.Repository + } + $state.Installed = $this.Test() + return $state + } + + [bool] Test() { + $result = Invoke-DscCopilot -Arguments @('plugin', 'marketplace', 'list') + return Test-DscListContainsToken -Lines $result.Output -Token $this.Name + } + + [void] Set() { + Assert-DscSafeArgument -Value $this.Name -Name 'Name' + Assert-DscSafeArgument -Value $this.Repository -Name 'Repository' + $result = Invoke-DscCopilot -Arguments @('plugin', 'marketplace', 'add', $this.Name, $this.Repository) + if ($result.ExitCode -ne 0) { + throw "Failed to register Copilot marketplace '$($this.Name)': $($result.Output -join '; ')" + } + } +} + +<# +.SYNOPSIS + Installs a Python tool via uv. + +.DESCRIPTION + DSC resource that ensures a Python tool is installed via 'uv tool install'. + Tests by checking whether the tool name appears as a whole token in + 'uv tool list' output. + + Depends only on the public uv CLI on PATH. + +.PROPERTY Name + The name of the Python tool to install. This is the key property. + +.PROPERTY Installed + Read-only. Reports whether the tool is installed. + +.EXAMPLE + - name: Install a tool + type: Shmuelie.Dsc/UvTool + properties: + Name: fast-agent-mcp +#> +[DscResource()] +class UvTool { + [DscProperty(Key)] + [string] $Name + + [DscProperty(NotConfigurable)] + [bool] $Installed + + [UvTool] Get() { + $state = [UvTool]@{ + Name = $this.Name + } + $state.Installed = $this.Test() + return $state + } + + [bool] Test() { + $result = Invoke-DscUv -Arguments @('tool', 'list') + return Test-DscListContainsToken -Lines $result.Output -Token $this.Name + } + + [void] Set() { + Assert-DscSafeArgument -Value $this.Name -Name 'Name' + $result = Invoke-DscUv -Arguments @('tool', 'install', $this.Name) + if ($result.ExitCode -ne 0) { + throw "Failed to install uv tool '$($this.Name)': $($result.Output -join '; ')" + } + } +} diff --git a/tests/Shmuelie.Dsc.Tests.ps1 b/tests/Shmuelie.Dsc.Tests.ps1 new file mode 100644 index 0000000..f4f2282 --- /dev/null +++ b/tests/Shmuelie.Dsc.Tests.ps1 @@ -0,0 +1,275 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.2.0' } + +BeforeAll { + $repoRoot = Split-Path (Split-Path $PSCommandPath -Parent) -Parent + $script:ModuleManifest = [System.IO.Path]::Combine($repoRoot, 'modules', 'Shmuelie.Dsc', 'Shmuelie.Dsc.psd1') + Import-Module $script:ModuleManifest -Force +} + +AfterAll { + Remove-Module Shmuelie.Dsc -Force -ErrorAction SilentlyContinue +} + +Describe 'Shmuelie.Dsc module' { + It 'exports the expected DSC resources' { + $data = Import-PowerShellDataFile $script:ModuleManifest + ($data.DscResourcesToExport | Sort-Object) | Should -Be (@('SavePSResource', 'SymbolicLink', 'CopilotPlugin', 'CopilotMarketplace', 'UvTool') | Sort-Object) + } + + It 'exports no functions or aliases' { + $data = Import-PowerShellDataFile $script:ModuleManifest + $data.FunctionsToExport.Count | Should -Be 0 + $data.AliasesToExport.Count | Should -Be 0 + } +} + +Describe 'Private helpers' { + It 'strips ANSI escape sequences from CLI output' { + InModuleScope Shmuelie.Dsc { + $esc = [char]27 + Remove-DscAnsiEscape "$esc[32mfast-agent-mcp$esc[0m v1.2.3" | Should -Be 'fast-agent-mcp v1.2.3' + } + } + + It 'matches whole tokens, not substrings' { + InModuleScope Shmuelie.Dsc { + Test-DscListContainsToken -Lines @('fast-agent-mcp v1.2.3') -Token 'fast-agent-mcp' | Should -BeTrue + Test-DscListContainsToken -Lines @('fast-agent-mcp v1.2.3') -Token 'mcp' | Should -BeFalse + Test-DscListContainsToken -Lines @() -Token 'anything' | Should -BeFalse + } + } + + It 'rejects shell-unsafe arguments' { + InModuleScope Shmuelie.Dsc { + { Assert-DscSafeArgument -Value 'owner/repo' -Name 'Source' } | Should -Not -Throw + { Assert-DscSafeArgument -Value 'owner/repo & calc.exe' -Name 'Source' } | Should -Throw '*not allowed*' + } + } +} + +Describe 'SavePSResource' { + It 'is absent when the module folder does not exist and present once it does' { + InModuleScope Shmuelie.Dsc -Parameters @{ Root = $TestDrive } { + param($Root) + + $dir = Join-Path $Root ([guid]::NewGuid()) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $resource = [SavePSResource]@{ Name = 'Pester'; Path = $dir } + $resource.Test() | Should -BeFalse + + New-Item -ItemType Directory -Path (Join-Path $dir 'Pester') -Force | Out-Null + $resource.Test() | Should -BeTrue + } + } + + It 'honors an explicit Version by checking the versioned subfolder' { + InModuleScope Shmuelie.Dsc -Parameters @{ Root = $TestDrive } { + param($Root) + + $dir = Join-Path $Root ([guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $dir 'Pester') -Force | Out-Null + ([SavePSResource]@{ Name = 'Pester'; Path = $dir; Version = '5.5.0' }).Test() | Should -BeFalse + + New-Item -ItemType Directory -Path (Join-Path $dir 'Pester\5.5.0') -Force | Out-Null + ([SavePSResource]@{ Name = 'Pester'; Path = $dir; Version = '5.5.0' }).Test() | Should -BeTrue + } + } + + It 'saves from the requested repository into the requested path' { + InModuleScope Shmuelie.Dsc -Parameters @{ Root = $TestDrive } { + param($Root) + + Mock Save-PSResource { } + ([SavePSResource]@{ Name = 'Pester'; Path = $Root; Repository = 'PSGallery' }).Set() + + Should -Invoke Save-PSResource -Times 1 -Exactly -ParameterFilter { + $Name -eq 'Pester' -and $Path -eq $Root -and $Repository -eq 'PSGallery' + } + } + } + + It 'passes an explicit Version to Save-PSResource' { + InModuleScope Shmuelie.Dsc -Parameters @{ Root = $TestDrive } { + param($Root) + + Mock Save-PSResource { } + ([SavePSResource]@{ Name = 'Pester'; Path = $Root; Version = '5.5.0' }).Set() + + Should -Invoke Save-PSResource -Times 1 -Exactly -ParameterFilter { $Version -eq '5.5.0' } + } + } + + It 'Get() reports Installed and defaults Repository to PSGallery' { + InModuleScope Shmuelie.Dsc -Parameters @{ Root = $TestDrive } { + param($Root) + + $dir = Join-Path $Root ([guid]::NewGuid()) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $resource = [SavePSResource]@{ Name = 'Pester'; Path = $dir } + $resource.Repository | Should -Be 'PSGallery' + $resource.Get().Installed | Should -BeFalse + + New-Item -ItemType Directory -Path (Join-Path $dir 'Pester') -Force | Out-Null + $resource.Get().Installed | Should -BeTrue + } + } +} + +Describe 'SymbolicLink' { + It 'is not in the desired state when the path is missing' { + InModuleScope Shmuelie.Dsc { + Mock Get-Item { $null } + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\target' }).Test() | Should -BeFalse + } + } + + It 'is not in the desired state when the item is not a symbolic link' { + InModuleScope Shmuelie.Dsc { + Mock Get-Item { [pscustomobject]@{ LinkType = $null; Target = 'C:\target' } } + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\target' }).Test() | Should -BeFalse + } + } + + It 'is in the desired state only when the link target matches' { + InModuleScope Shmuelie.Dsc { + Mock Get-Item { [pscustomobject]@{ LinkType = 'SymbolicLink'; Target = 'C:\target' } } + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\target' }).Test() | Should -BeTrue + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\other' }).Test() | Should -BeFalse + } + } + + It 'Get() reports the actual current target across all three states' { + InModuleScope Shmuelie.Dsc { + Mock Get-Item { $null } + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\target' }).Get().Target | Should -Be '' + + Mock Get-Item { [pscustomobject]@{ LinkType = $null; Target = 'C:\whatever' } } + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\target' }).Get().Target | Should -Be '' + + Mock Get-Item { [pscustomobject]@{ LinkType = 'SymbolicLink'; Target = 'C:\real' } } + ([SymbolicLink]@{ Path = 'C:\link'; Target = 'C:\target' }).Get().Target | Should -Be 'C:\real' + } + } + + It 'creates the parent directory and the symbolic link' { + InModuleScope Shmuelie.Dsc -Parameters @{ Root = $TestDrive } { + param($Root) + + Mock New-DscSymbolicLink { } + $linkPath = Join-Path $Root 'sub\link' + + ([SymbolicLink]@{ Path = $linkPath; Target = 'C:\target' }).Set() + + Test-Path -LiteralPath (Join-Path $Root 'sub') | Should -BeTrue + Should -Invoke New-DscSymbolicLink -Times 1 -Exactly -ParameterFilter { + $Path -eq $linkPath -and $Target -eq 'C:\target' + } + } + } +} + +Describe 'CopilotPlugin' { + It 'detects an installed plugin by whole-token match (owner/repo and plugin@marketplace)' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = @('my-plugin installed'); ExitCode = 0 } } + ([CopilotPlugin]@{ Source = 'owner/my-plugin' }).Test() | Should -BeTrue + ([CopilotPlugin]@{ Source = 'my-plugin@some-market' }).Test() | Should -BeTrue + } + } + + It 'does not false-positive when the desired name is a substring of an installed one' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = @('changelog installed'); ExitCode = 0 } } + ([CopilotPlugin]@{ Source = 'owner/log' }).Test() | Should -BeFalse + } + } + + It 'resolves the name from a market: source and from an explicit Name for URL sources' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = @('my-plugin installed'); ExitCode = 0 } } + ([CopilotPlugin]@{ Source = 'market:my-plugin@dotnet/skills' }).Test() | Should -BeTrue + ([CopilotPlugin]@{ Source = 'https://example.com/x/my-plugin.zip'; Name = 'my-plugin' }).Test() | Should -BeTrue + ([CopilotPlugin]@{ Source = 'https://example.com/x/my-plugin.zip' }).Test() | Should -BeFalse + } + } + + It 'installs the source, includes CLI output in errors, and throws on a non-zero exit code' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = 'ok'; ExitCode = 0 } } + { ([CopilotPlugin]@{ Source = 'owner/my-plugin' }).Set() } | Should -Not -Throw + Should -Invoke Invoke-DscCopilot -ParameterFilter { $Arguments -join ' ' -eq 'plugin install owner/my-plugin' } + + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = 'auth failed'; ExitCode = 1 } } + { ([CopilotPlugin]@{ Source = 'owner/my-plugin' }).Set() } | Should -Throw '*auth failed*' + } + } + + It 'rejects a shell-unsafe Source before invoking the CLI' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = 'ok'; ExitCode = 0 } } + { ([CopilotPlugin]@{ Source = 'owner/repo & calc.exe' }).Set() } | Should -Throw '*not allowed*' + Should -Invoke Invoke-DscCopilot -Times 0 + } + } +} + +Describe 'CopilotMarketplace' { + It 'detects a registered marketplace by whole-token match and avoids substring false positives' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = @('dotnet-skills dotnet/skills'); ExitCode = 0 } } + ([CopilotMarketplace]@{ Name = 'dotnet-skills'; Repository = 'dotnet/skills' }).Test() | Should -BeTrue + ([CopilotMarketplace]@{ Name = 'dotnet'; Repository = 'dotnet/skills' }).Test() | Should -BeFalse + } + } + + It 'registers the marketplace and throws (with output) on a non-zero exit code' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = 'ok'; ExitCode = 0 } } + { ([CopilotMarketplace]@{ Name = 'dotnet-skills'; Repository = 'dotnet/skills' }).Set() } | Should -Not -Throw + Should -Invoke Invoke-DscCopilot -ParameterFilter { + $Arguments -join ' ' -eq 'plugin marketplace add dotnet-skills dotnet/skills' + } + + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = 'nope'; ExitCode = 2 } } + { ([CopilotMarketplace]@{ Name = 'dotnet-skills'; Repository = 'dotnet/skills' }).Set() } | Should -Throw '*nope*' + } + } + + It 'rejects shell-unsafe Name or Repository before invoking the CLI' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscCopilot { [pscustomobject]@{ Output = 'ok'; ExitCode = 0 } } + { ([CopilotMarketplace]@{ Name = 'bad&name'; Repository = 'x/y' }).Set() } | Should -Throw '*not allowed*' + Should -Invoke Invoke-DscCopilot -Times 0 + } + } +} + +Describe 'UvTool' { + It 'detects an installed tool and avoids substring false positives' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscUv { [pscustomobject]@{ Output = @('fast-agent-mcp v1.2.3', '- fast-agent'); ExitCode = 0 } } + ([UvTool]@{ Name = 'fast-agent-mcp' }).Test() | Should -BeTrue + ([UvTool]@{ Name = 'mcp' }).Test() | Should -BeFalse + ([UvTool]@{ Name = 'not-installed' }).Test() | Should -BeFalse + } + } + + It 'Get() reports Installed via Test()' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscUv { [pscustomobject]@{ Output = @('fast-agent-mcp v1.2.3'); ExitCode = 0 } } + ([UvTool]@{ Name = 'fast-agent-mcp' }).Get().Installed | Should -BeTrue + ([UvTool]@{ Name = 'absent' }).Get().Installed | Should -BeFalse + } + } + + It 'installs the tool and throws (with output) on a non-zero exit code' { + InModuleScope Shmuelie.Dsc { + Mock Invoke-DscUv { [pscustomobject]@{ Output = 'ok'; ExitCode = 0 } } + { ([UvTool]@{ Name = 'fast-agent-mcp' }).Set() } | Should -Not -Throw + Should -Invoke Invoke-DscUv -ParameterFilter { $Arguments -join ' ' -eq 'tool install fast-agent-mcp' } + + Mock Invoke-DscUv { [pscustomobject]@{ Output = 'network error'; ExitCode = 1 } } + { ([UvTool]@{ Name = 'fast-agent-mcp' }).Set() } | Should -Throw '*network error*' + } + } +}