Shmuelie.Dsc.psm1

# 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 '; ')"
        }
    }
}