From b9c45c87b2a62b479e107979ab3714073a765e8e Mon Sep 17 00:00:00 2001 From: Gijs Reijn Date: Thu, 28 Nov 2024 23:00:46 +0100 Subject: [PATCH] Add whatif and export support NpmDsc (#132) --- .github/actions/spelling/allow.txt | 3 + .../actions/spelling/expect/generic_terms.txt | 3 +- .github/actions/spelling/expect/software.txt | 3 +- resources/Help/NpmDsc/NpmPackage.md | 54 +++ resources/NpmDsc/NpmDsc.psm1 | 353 ++++++++++++------ tests/NpmDsc/NpmDsc.tests.ps1 | 153 ++++++++ 6 files changed, 462 insertions(+), 107 deletions(-) create mode 100644 resources/Help/NpmDsc/NpmPackage.md create mode 100644 tests/NpmDsc/NpmDsc.tests.ps1 diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index d1358a42..1c720079 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -65,3 +65,6 @@ videojs vsconfig websites wekyb +Hmmss +MMdd +MMdd \ No newline at end of file diff --git a/.github/actions/spelling/expect/generic_terms.txt b/.github/actions/spelling/expect/generic_terms.txt index 5180255f..870c3e87 100644 --- a/.github/actions/spelling/expect/generic_terms.txt +++ b/.github/actions/spelling/expect/generic_terms.txt @@ -18,4 +18,5 @@ sortby ssh usr versioning -VGpu \ No newline at end of file +VGpu +ADDLOCAL diff --git a/.github/actions/spelling/expect/software.txt b/.github/actions/spelling/expect/software.txt index eb235278..31950688 100644 --- a/.github/actions/spelling/expect/software.txt +++ b/.github/actions/spelling/expect/software.txt @@ -4,4 +4,5 @@ dotnet dotnettool NUnit reportgenerator -markdownlint \ No newline at end of file +markdownlint +msiexec diff --git a/resources/Help/NpmDsc/NpmPackage.md b/resources/Help/NpmDsc/NpmPackage.md new file mode 100644 index 00000000..a2e80d90 --- /dev/null +++ b/resources/Help/NpmDsc/NpmPackage.md @@ -0,0 +1,54 @@ +--- +external help file: NpmDsc.psm1-Help.xml +Module Name: NpmDsc +ms.date: 11/16/2024 +online version: +schema: 2.0.0 +title: NpmPackage +--- + +# NpmPackage + +## SYNOPSIS + +The `NpmPackage` DSC Resource allows you to manage the installation, update, and removal of npm packages. This resource ensures that the specified npm package is in the desired state. + +## DESCRIPTION + +The `NpmPackage` DSC Resource allows you to manage the installation, update, and removal of npm packages. This resource ensures that the specified npm package is in the desired state. + +## PARAMETERS + +| **Parameter** | **Attribute** | **DataType** | **Description** | **Allowed Values** | +| ------------------ | -------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `Ensure` | Optional | String | Specifies whether the npm package should be present or absent. The default value is `Present`. | `Present`, `Absent` | +| `Name` | Key, Mandatory | String | The name of the npm package to manage. This is a key property. | Any valid npm package name | +| `Version` | Optional | String | The version of the npm package to install. If not specified, the latest version will be installed. | Any valid version string (e.g., `4.17.1`) | +| `PackageDirectory` | Optional | String | The directory where the npm package should be installed. If not specified, the package will be installed in the current directory. | Any valid directory path | +| `Global` | Optional | Boolean | Indicates whether the npm package should be installed globally. The default value is `$false`. | `$true`, `$false` | + +## EXAMPLES + +### EXAMPLE 1 - Install React package in default directory + +```powershell +PS C:\> Invoke-DscResource -ModuleName NpmDsc -Name NpmPackage -Method Set -Property @{ Name = 'react' } + +# This example installs the npm package 'react' in the current directory. +``` + +### EXAMPLE 2 - Install Babel package in global directory + +```powershell +PS C:\> Invoke-DscResource -ModuleName NpmDsc -Name NpmPackage -Method Set -Property @{ Name = 'babel'; Global = $true } + +# This example installs the npm package 'babel' globally. +``` + +### EXAMPLE 3 - Get WhatIf result for React package + +```powershell +PS C:\> ([NpmPackage]@{ Name = 'react' }).WhatIf() + +# This example returns the whatif result for installing the npm package 'react'. Note: This does not actually install the package and requires the module to be imported using 'using module '. +``` diff --git a/resources/NpmDsc/NpmDsc.psm1 b/resources/NpmDsc/NpmDsc.psm1 index 291da606..64f7b4bc 100644 --- a/resources/NpmDsc/NpmDsc.psm1 +++ b/resources/NpmDsc/NpmDsc.psm1 @@ -1,12 +1,189 @@ using namespace System.Collections.Generic -#Region '.\Enum\Ensure.ps1' 0 + +#region Functions +function Assert-Npm { + # Refresh session $path value before invoking 'npm' + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') + try { + Invoke-Npm -Command 'help' + return + } catch { + throw 'NodeJS is not installed' + } +} + +function Invoke-Npm { + param ( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + return Invoke-Expression -Command "npm $Command" +} + +function Set-PackageDirectory { + param ( + [Parameter(Mandatory = $true)] + [string]$PackageDirectory + ) + + if (Test-Path -Path $PackageDirectory -PathType Container) { + Set-Location -Path $PackageDirectory + } else { + throw "$($PackageDirectory) does not point to a valid directory." + } +} + +function Get-InstalledNpmPackages { + param ( + [Parameter()] + [bool]$Global + ) + + $command = [List[string]]::new() + $command.Add('list') + $command.Add('--json') + + if ($Global) { + $command.Add('-g') + } + + return Invoke-Npm -Command $command +} + +function Install-NpmPackage { + param ( + [Parameter()] + [string]$PackageName, + + [Parameter()] + [bool]$Global, + + [Parameter()] + [string]$Arguments + ) + + $command = [List[string]]::new() + $command.Add('install') + $command.Add($PackageName) + + if ($Global) { + $command.Add('-g') + } + + $command.Add($Arguments) + + Write-Verbose -Message "Executing 'npm $command'" + + return Invoke-Npm -Command $command +} + +function Uninstall-NpmPackage { + param ( + [Parameter(Mandatory = $true)] + [string]$PackageName, + + [Parameter()] + [bool]$Global, + + [Parameter()] + [string]$Arguments + ) + + $command = [List[string]]::new() + $command.Add('uninstall') + $command.Add($PackageName) + + if ($Global) { + $command.Add('-g') + } + + $command.Add($Arguments) + + Write-Verbose -Message "Executing 'npm $command'" + + return Invoke-Npm -Command $command +} + +function GetNpmPath { + if ($IsWindows) { + $npmCacheDir = Join-Path $env:LOCALAPPDATA 'npm-cache' '_logs' + $globalNpmCacheDir = Join-Path $env:SystemDrive 'npm' 'cache' '_logs' + if (Test-Path $npmCacheDir -ErrorAction SilentlyContinue) { + return $npmCacheDir + } elseif (Test-Path $globalNpmCacheDir -ErrorAction SilentlyContinue) { + return $globalNpmCacheDir + } else { + $result = (Invoke-Npm -Command 'config list --json' | ConvertFrom-Json -ErrorAction SilentlyContinue).cache + if (Test-Path $result -ErrorAction SilentlyContinue) { + return $result + } else { + return $null + } + } + } elseif ($IsLinux -or $IsMacOS) { + $npmCacheDir = Join-Path $env:HOME '.npm/_logs' + if (Test-Path $npmCacheDir -ErrorAction SilentlyContinue) { + return $npmCacheDir + } else { + return $null + } + } else { + throw 'Unsupported platform' + } +} + +function GetNpmWhatIfResponse { + $npmPath = GetNpmPath + if ($null -ne $npmPath) { + return (Get-NpmErrorMessages -LogPath $npmPath) + } else { + return @('No what-if response found.') + } +} + +function Get-NpmErrorMessages { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$LogPath + ) + + $lastLog = (Get-ChildItem $LogPath -Filter '*.log' | Sort-Object LastWriteTime -Descending)[0] + + Write-Verbose -Message "Found logging cache entry: $($lastLog.FullName)" + + $errorMessages = @() + if ($lastLog) { + $logContent = Get-Content $lastLog.FullName + $regex = [regex]::new('^error\s.*') + + foreach ($line in $logContent) { + $lineRemovePattern = '^\d+\s*' + + $cleanedLine = $line -replace $lineRemovePattern, '' + if ($regex.Matches($cleanedLine)) { + $errorMessages += $cleanedLine + } + } + + if ([string]::IsNullOrEmpty($errorMessages)) { + $errorMessages = @('No what-if response found.') + } + + return $errorMessages + } +} +#endRegion Functions + +#region Enums enum Ensure { Absent Present } -#EndRegion '.\Enum\Ensure.ps1' 6 -#Region '.\Classes\DSCResources\NpmInstall.ps1' 0 -#using namespace System.Collections.Generic +#endRegion Enums + +#region DSCResources [DSCResource()] class NpmInstall { [DscProperty()] @@ -69,8 +246,41 @@ class NpmInstall { } } } -#EndRegion '.\Classes\DSCResources\NpmInstall.ps1' 77 -#Region '.\Classes\DSCResources\NpmPackage.ps1' 0 + +<# +.SYNOPSIS + The `NpmPackage` DSC Resource allows you to manage the installation, update, and removal of npm packages. This resource ensures that the specified npm package is in the desired state. + +.PARAMETER Ensure + Specifies whether the npm package should be present or absent. The default value is `Present`. + +.PARAMETER Name + The name of the npm package to manage. This is a key property. + +.PARAMETER Version + The version of the npm package to install. If not specified, the latest version will be installed. + +.PARAMETER PackageDirectory + The directory where the npm package should be installed. If not specified, the package will be installed in the current directory. + +.PARAMETER Global + Indicates whether the npm package should be installed globally. + +.EXAMPLE + PS C:\> Invoke-DscResource -ModuleName NpmDsc -Name NpmPackage -Method Set -Property @{ Name = 'react' } + + This example installs the npm package 'react' in the current directory. + +.EXAMPLE + PS C:\> Invoke-DscResource -ModuleName NpmDsc -Name NpmPackage -Method Set -Property @{ Name = 'babel'; Global = $true } + + This example installs the npm package 'babel' globally. + +.EXAMPLE + PS C:\> ([NpmPackage]@{ Name = 'react' }).WhatIf() + + This example returns the whatif result for installing the npm package 'react'. Note: This does not actually install the package and requires the module to be imported using 'using module '. +#> [DSCResource()] class NpmPackage { [DscProperty()] @@ -141,111 +351,44 @@ class NpmPackage { } } } -} -#EndRegion '.\Classes\DSCResources\NpmPackage.ps1' 87 -#Region '.\Private\Assert-Npm.ps1' 0 -function Assert-Npm { - # Refresh session $path value before invoking 'npm' - $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') - try { - Invoke-Npm -Command 'help' - return - } catch { - throw 'NodeJS is not installed' - } -} -#EndRegion '.\Private\Assert-Npm.ps1' 15 -#Region '.\Private\Invoke-Npm.ps1' 0 -function Invoke-Npm { - param ( - [Parameter(Mandatory = $true)] - [string]$Command - ) - - return Invoke-Expression -Command "npm $Command" -} -#EndRegion '.\Private\Invoke-Npm.ps1' 10 -#Region '.\Private\Set-PackageDirectory.ps1' 0 -function Set-PackageDirectory { - param ( - [Parameter(Mandatory = $true)] - [string]$PackageDirectory - ) - if (Test-Path -Path $PackageDirectory -PathType Container) { - Set-Location -Path $PackageDirectory - } else { - throw "$($PackageDirectory) does not point to a valid directory." - } -} -#EndRegion '.\Private\Set-PackageDirectory.ps1' 17 -#Region '.\Public\Get-InstalledNpmPackages.ps1' 0 -function Get-InstalledNpmPackages { - param ( - [Parameter()] - [bool]$Global - ) - - $command = [List[string]]::new() - $command.Add('list') - $command.Add('--json') - - if ($Global) { - $command.Add('-g') - } - - return Invoke-Npm -Command $command -} -#EndRegion '.\Public\Get-InstalledNpmPackages.ps1' 19 -#Region '.\Public\Install-NpmPackage.ps1' 0 -function Install-NpmPackage { - param ( - [Parameter()] - [string]$PackageName, - - [Parameter()] - [bool]$Global, - - [Parameter()] - [string]$Arguments - ) + static [NpmPackage[]] Export() { + $packages = Get-InstalledNpmPackages -Global $true | ConvertFrom-Json -AsHashtable | Select-Object -ExpandProperty dependencies + $out = [List[NpmPackage]]::new() + $globalDir = (Join-Path -Path (Invoke-Npm -Command 'config get prefix') -ChildPath 'node_modules') + foreach ($package in $packages.GetEnumerator()) { + $in = [NpmPackage]@{ + Name = $package.Name + Version = $package.Value.version + Ensure = [Ensure]::Present + Global = $true + Arguments = $null + PackageDirectory = $globalDir + } - $command = [List[string]]::new() - $command.Add('install') - $command.Add($PackageName) + $out.Add($in) + } - if ($Global) { - $command.Add('-g') + return $out } - $command.Add($Arguments) - - return Invoke-Npm -Command $command -} -#EndRegion '.\Public\Install-NpmPackage.ps1' 27 -#Region '.\Public\Uninstall-NpmPackage.ps1' 0 -function Uninstall-NpmPackage { - param ( - [Parameter(Mandatory = $true)] - [string]$PackageName, - - [Parameter()] - [bool]$Global, - - [Parameter()] - [string]$Arguments - ) + [string] WhatIf() { + if ($this.Ensure -eq [Ensure]::Present) { + $whatIfState = Install-NpmPackage -PackageName $this.Name -Global $this.Global -Arguments '--dry-run' - $command = [List[string]]::new() - $command.Add('uninstall') - $command.Add($PackageName) + $out = @{ + Name = $this.Name + _metaData = @{ + whatif = @() + } + } + $out._metaData.whatif = $LASTEXITCODE -ne 0 ? (GetNpmWhatIfResponse) : ($whatIfState | Where-Object { $_.Trim() -ne '' }) # Removes empty lines from response + } else { + # Uninstall does not have --dry-run param + $out = @{} + } - if ($Global) { - $command.Add('-g') + return ($out | ConvertTo-Json -Depth 10 -Compress) } - - $command.Add($Arguments) - - return Invoke-Npm -Command $command } -#EndRegion '.\Public\Uninstall-NpmPackage.ps1' 27 +#endRegion DSCResources diff --git a/tests/NpmDsc/NpmDsc.tests.ps1 b/tests/NpmDsc/NpmDsc.tests.ps1 new file mode 100644 index 00000000..3162aeee --- /dev/null +++ b/tests/NpmDsc/NpmDsc.tests.ps1 @@ -0,0 +1,153 @@ +using module NpmDsc + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +<# +.Synopsis + Pester tests related to the NpmDsc PowerShell module. +#> + +BeforeAll { + # Before import module make sure NpmDsc is installed + Import-Module NpmDsc -Force -ErrorAction SilentlyContinue + + if ($env:TF_BUILD) { + $versionsUri = 'https://nodejs.org/dist/index.json' + Write-Verbose -Message "Checking NodeJS versions from $versionsUri" -Verbose + $versions = Invoke-RestMethod -Uri $versionsUri -UseBasicParsing + + $latestVersion = $versions[0].version + $fileName = "node-$latestVersion-x64.msi" + $64uri = "https://nodejs.org/dist/$latestVersion/$fileName" + $outFile = Join-Path -Path $env:TEMP -ChildPath $fileName + + Write-Verbose -Message "Downloading $64uri to $outFile" -Verbose + Invoke-RestMethod -Uri $64uri -OutFile $outFile -UseBasicParsing + + # Install NodeJS + $DataStamp = Get-Date -Format yyyyMMddTHHmmss + $logFile = '{0}-{1}.log' -f "node-$latestVersion-x64", $DataStamp + $arguments = @( + '/i' + ('"{0}"' -f $outFile) + '/quiet' + 'ADDLOCAL=ALL' + '/L*v' + $logFile + ) + Start-Process 'msiexec.exe' -ArgumentList $arguments -Wait -NoNewWindow + + Write-Verbose -Message ("Finished installing NodeJS: '{0}'" -f (node --version)) -Verbose + + # Clean up the npm cache log directory + $logFiles = Get-ChildItem (GetNpmPath) -Filter '*.log' -Recurse -ErrorAction SilentlyContinue + $logFiles | Remove-Item -Force -ErrorAction SilentlyContinue + } + + # Reduce the noise for npm + $env:NODE_OPTIONS = '--disable-warning=ExperimentalWarning' +} + +Describe 'List available DSC resources' { + It 'Shows DSC Resources' { + $expectedDSCResources = 'NpmPackage', 'NpmInstall' + $availableDSCResources = (Get-DscResource -Module NpmDsc).Name + $availableDSCResources.count | Should -Be 2 + $availableDSCResources | Where-Object { $expectedDSCResources -notcontains $_ } | Should -BeNullOrEmpty -ErrorAction Stop + } +} + +Describe 'NpmPackage' { + It 'Install react package globally' -Skip:(!$IsWindows) { + $desiredState = @{ + Name = 'react' + Global = $true + } + + Invoke-DscResource -Name NpmPackage -ModuleName NpmDsc -Method Set -Property $desiredState + + $finalState = Invoke-DscResource -Name NpmPackage -ModuleName NpmDsc -Method Get -Property $desiredState + $finalState.Name | Should -Be $desiredState.Name + $finalState.Ensure | Should -Be 'Present' + } + + It 'Uninstall react package globally' -Skip:(!$IsWindows) { + $desiredState = @{ + Name = 'react' + Global = $true + Ensure = 'Absent' + } + + Invoke-DscResource -Name NpmPackage -ModuleName NpmDsc -Method Set -Property $desiredState + + $finalState = Invoke-DscResource -Name NpmPackage -ModuleName NpmDsc -Method Test -Property $desiredState + $finalState.InDesiredState | Should -Be $true + } + + It 'Performs whatif operation successfully' -Skip:(!$IsWindows) { + $whatIfState = @{ + Name = 'react' + Global = $true + Ensure = 'Absent' + } + + $npmPackage = [NpmPackage]$whatIfState + + # Uninstall to make sure it is not present + $npmPackage.Set() + + $npmPackage.Ensure = 'Present' + + # Call whatif to see if it "will" install + $whatIf = $npmPackage.WhatIf() | ConvertFrom-Json + + # Don't want to rely on version in parameters so we call npm view to get the remote version + $latestVersion = Invoke-Npm -Command "view $($whatIfState.Name) version" + + $whatIf.Name | Should -Be 'react' + $whatIf._metaData.whatIf | Should -Contain "add react $latestVersion" + } + + It 'Does not return whatif result if package is invalid' -Skip:(!$IsWindows) { + $whatIfState = @{ + Name = 'invalidPackageName' + Global = $true + } + + $npmPackage = [NpmPackage]$whatIfState + $whatIf = $npmPackage.WhatIf() | ConvertFrom-Json + + Write-Verbose -Message ($whatIf | ConvertTo-Json -Depth 5 | Out-String) -Verbose + + $whatIf.Name | Should -Be 'invalidPackageName' + $whatIf._metaData.whatIf | Should -Contain "error 404 Not Found - GET https://registry.npmjs.org/$($whatIfState.Name) - Not found" + } + + It 'Returns empty result if ensure is absent' -Skip:(!$IsWindows) { + $whatIfState = @{ + Name = 'invalidPackageName' + Ensure = 'Absent' + } + + $npmPackage = [NpmPackage]$whatIfState + $whatIf = $npmPackage.WhatIf() | ConvertFrom-Json + + $whatIf | Should -BeNullOrEmpty -Because "Uninstall does not have '--dry-run'" + } + + It 'Should be able to export npm packages' -Skip:(!$IsWindows) { + $whatIfState = @{ + Name = 'react' + Global = $true + } + + $npmPackage = [NpmPackage]$whatIfState + # Install at least one package + $npmPackage.Set() + + $exportedState = $npmPackage::Export() + + $exportedState.Count | Should -BeGreaterOrEqual 1 + } +}