diff --git a/README.md b/README.md index e7e66e7..115ec68 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Parcel +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Badgerati/Parcel/master/LICENSE.txt) +[![PowerShell](https://img.shields.io/powershellgallery/dt/parcel.svg?label=PowerShell&colorB=085298)](https://www.powershellgallery.com/packages/Parcel) + > This is still a work in progress! Parcel is a cross-platform PowerShell package manager and provisioner for a number of different package managers. @@ -10,26 +13,33 @@ You define a package file using YAML, and Parcel will install/uninstall the pack These are the currently support package providers (more to come!): -* Chocolatey (choco) -* PowerShell Gallery (psgallery) -* Scoop (scoop) -* More to come, like brew, yum, docker, etc. +* Chocolatey +* PowerShell Gallery +* Scoop +* Homebrew +* Docker +* Windows Features +* Apt-get +* Yum + +## Install + +```powershell +Install-Module -Name Parcel +``` ## Usage You define the packages using a YAML file - the default is `parcel.yml`, but can be anything. Then, you can run one of the following: ```powershell -Import-Module ./src/Parcel.psd1 -Force - -# then -Install-ParcelPackages [-Path ] [-Environment ] [-IgnoreEnsures] [-WhatIf] -Uninstall-ParcelPackages [-Path ] [-Environment ] [-IgnoreEnsures] [-WhatIf] +Install-ParcelPackages [-Path ] [-Environment ] [-IgnoreEnsures] [-WhatIf] [-Verbose] +Uninstall-ParcelPackages [-Path ] [-Environment ] [-IgnoreEnsures] [-WhatIf] [-Verbose] ``` ## Examples -To install 7zip using Chocolatey, the following could be used. For each `name` and `provider` are mandatory. +To install 7zip using Chocolatey, the following could be used. For each `name`/`names` and `provider` are mandatory, and the name *must* match precisely on all providers: ```yaml --- @@ -60,7 +70,7 @@ The properties that are currently supported are in packages are: * args (extra arguments to run, can also be split into `install:` and `uninstall:`) * ensure (can be empty, or present/absent) * os (can be windows, linux, or macos - package will only run if running on that OS) -* environment (can be anything, default is 'none'. packages will run based on `-Environment`) +* environment (can be anything, default is 'all'. packages will run based on `-Environment`) * when (powershell script that returns a boolean value, if true then package will run) * pre/post scritps (allows you to define powershell scripts to run pre/post install/uninstall) @@ -69,8 +79,9 @@ There is also a scripts block that allows for defining pre/post scripts that run ```yaml --- packages: -- name: - provider: +- name: + names: + provider: provider-name> version: source: args: @@ -78,7 +89,7 @@ packages: uninstall: ensure: os: - environment: + environment: when: pre: install: @@ -121,3 +132,112 @@ $parcel = @{ } } ``` + +## Providers + +### Chocolatey + +```yaml +packages: +- name: 7zip.install + provider: + version: 19.0 +``` + +### Scoop + +```yaml +packages: +- name: 7zip + provider: scoop + version: 19.00 +``` + +### PowerShell Gallery + +```yaml +packages: +- name: Pester + provider: + version: 4.8.0 +``` + +### Homebrew + +* Self-installation is not supported due to some quirks with PowerShell +* Sources are not supported, due to Homebrew not having them +* Latest does work, but Parcel cannot retrieve the latest version as Homebrew doesn't display it + * Since Parcel can't get the latest version, using it will always "Change" + +```yaml +packages: +- name: p7zip + provider: +``` + +> If you face issues installing casks, try running: `sudo chown -R $USER:admin /usr/local/Caskroom` + +### Docker + +* Self-installation is not supported - it's best to include this as another package to be installed +* Sources are not supported, due to Docker not having them (unless you pre-login to your own registry first) + +```yaml +packages: +- name: badgerati/pode + provider: docker + version: 1.1.0 +``` + +### Windows Features + +* Only supported in Windows (Desktop PowerShell only) +* Version is always latest (as features have no version) + +```yaml +packages: +- name: Microsoft-Hyper-V + provider: +``` + +### DISM + +* Only supported in Windows (Desktop/Core PowerShell) +* Version is always latest + +```yaml +- name: ActiveDirectory-PowerShell + provider: +``` + +### Apt-Get + +* Self-installation is not supported - if `apt-get` is not there, Parcel will fail +* Sources are not supported + +```yaml +packages: +- name: vim + provider: + version: latest +``` + +or: + +```yaml +packages: +- name: vim + provider: + version: 2:7.4.1689-3ubuntu1.3 +``` + +### Yum + +* Self-installation is not supported - if `yum` is not there, Parcel will fail +* Sources are not supported + +```yaml +packages: +- name: ansible + provider: yum +``` diff --git a/examples/centos.yml b/examples/centos.yml new file mode 100644 index 0000000..6295e92 --- /dev/null +++ b/examples/centos.yml @@ -0,0 +1,7 @@ +--- +packages: +- name: Pester + provider: psgallery + +- name: git + provider: yum \ No newline at end of file diff --git a/examples/parcel.yml b/examples/parcel.yml index 4d9ad43..a4acc88 100644 --- a/examples/parcel.yml +++ b/examples/parcel.yml @@ -1,8 +1,8 @@ --- packages: - name: 7zip.install - #provider: choco - provider: scoop + provider: choco + #provider: scoop version: 19.0 #os: linux #when: ($parcel.os.type -eq 'linux') @@ -13,4 +13,7 @@ packages: - name: pester provider: psgallery - version: 4.8.1 \ No newline at end of file + version: 4.8.1 + environment: + - developer + - devops \ No newline at end of file diff --git a/examples/sysadmin_mac.yml b/examples/sysadmin_mac.yml new file mode 100644 index 0000000..c2c0596 --- /dev/null +++ b/examples/sysadmin_mac.yml @@ -0,0 +1,29 @@ +--- +packages: +- name: docker + provider: brew +- name: spotify + provider: homebrew +- name: slack + provider: homebrew + version: 4.0.3 +- name: vagrant + provider: brew +- name: cheatsheet + provider: brew +- name: evernote + provider: brew +- name: slack + provider: brew +- name: visual-studio-code + provider: brew +- name: awscli + provider: brew +- name: azure-cli + provider: brew +- name: vlc + provider: brew +- name: skitch + provider: brew +- name: powershell + provider: brew \ No newline at end of file diff --git a/examples/sysadmin_win.yml b/examples/sysadmin_win.yml new file mode 100644 index 0000000..e2a47eb --- /dev/null +++ b/examples/sysadmin_win.yml @@ -0,0 +1,31 @@ +--- +packages: +- name: IIS-ManagementConsole + provider: winfeature +- name: RSAT + provider: DISM + source: c:\windows|sources\winsxs +- name: RSAT + provider: windowsfeatures +- name: ActiveDirectory-PowerShell + provider: dism +- name: RSAT-AD-Tools-Feature + provider: dism +- name: ServerManager-Core-RSAT + provider: dism +- name: RSAT-Hyper-V-Tools-Feature + provider: dism +- name: powershell-core + provider: choco +- name: notepadplusplus.install + provider: choco +- name: git.install + provider: choco +- name: vscode + provider: choco +- name: docker-desktop + provider: choco +- name: 7zip + provider: scoop +- name: treesizefree + provider: choco \ No newline at end of file diff --git a/examples/ubuntu.yml b/examples/ubuntu.yml new file mode 100644 index 0000000..20da184 --- /dev/null +++ b/examples/ubuntu.yml @@ -0,0 +1,7 @@ +--- +packages: +- name: Pester + provider: psgallery + +- name: vim + provider: apt-get \ No newline at end of file diff --git a/ideas.yml b/ideas.yml index c448933..57dfe04 100644 --- a/ideas.yml +++ b/ideas.yml @@ -1,6 +1,8 @@ # parcel.yml -# yes: choco, scoop, yum, apt-get, ps-gallery, nuget, win-features, script, brew, docker +# done: choco, scoop, ps-gallery, brew, docker +# yes: yum, apt-get, nuget, win-features, script # maybe: git, npm, yarn, bower (all global) +# others: windows-appx (win-store), snap # PACKAGE [ - - ] # > [updated|installed|uninstalled] diff --git a/src/Classes/ParcelFactory.ps1 b/src/Classes/ParcelFactory.ps1 index 4f66b24..1a5e04f 100644 --- a/src/Classes/ParcelFactory.ps1 +++ b/src/Classes/ParcelFactory.ps1 @@ -34,7 +34,7 @@ class ParcelFactory $this.Providers[$_name] = $_provider } - [int] InstallProviders([bool]$_dryRun) + [int] InstallProviders([hashtable]$_context, [bool]$_dryRun) { $_installed = 0 @@ -42,13 +42,15 @@ class ParcelFactory { $_provider = $this.Providers[$_name] - if ($_provider.TestProviderInstalled()) { + # do nothing if provider is installed + if ($_provider.TestProviderInstalled($_context)) { continue } + # otherwise, attempt at installing it Write-ParcelPackageHeader -Message "$($_provider.Name) [Provider]" - $result = $_provider.InstallProvider($_dryRun) + $result = $_provider.InstallProvider($_context, $_dryRun) $result.WriteStatusMessage($_dryRun) $_installed++ @@ -67,18 +69,42 @@ class ParcelFactory $_provider = $null switch ($_name.ToLowerInvariant()) { - 'choco' { + { @('choco', 'chocolatey') -icontains $_name } { $_provider = [ChocoParcelProvider]::new() } - 'psgallery' { + { @('psgallery', 'ps-gallery') -icontains $_name } { $_provider = [PSGalleryParcelProvider]::new() } - 'scoop' { + { @('scoop') -icontains $_name} { $_provider = [ScoopParcelProvider]::new() } + { @('brew', 'homebrew') -icontains $_name } { + $_provider = [BrewParcelProvider]::new() + } + + { @('docker') -icontains $_name} { + $_provider = [DockerParcelProvider]::new() + } + + { @('winfeature', 'win-feature', 'windows-feature', 'windowsfeatures', 'windows-features') -icontains $_name } { + $_provider = [WindowsFeatureParcelProvider]::new() + } + + { @('dism', 'windowsdism', 'win-dism') -icontains $_name } { + $_provider = [WindowsDISMParcelProvider]::new() + } + + { @('aptget', 'apt-get') -icontains $_name} { + $_provider = [AptGetParcelProvider]::new() + } + + { @('yum') -icontains $_name} { + $_provider = [YumParcelProvider]::new() + } + default { throw "Invalid package provider supplied: $($_name)" } diff --git a/src/Classes/ParcelPackage.ps1 b/src/Classes/ParcelPackage.ps1 index 46ceb15..1315ab6 100644 --- a/src/Classes/ParcelPackage.ps1 +++ b/src/Classes/ParcelPackage.ps1 @@ -2,7 +2,7 @@ class ParcelPackage { [string] $Name [string] $ProviderName - [string] $Source + [string[]] $Source [ParcelArguments] $Arguments [string] $Version @@ -10,58 +10,58 @@ class ParcelPackage [ParcelEnsureType] $Ensure [string] $When - [string] $Environment + [string[]] $Environment [ParcelOSType] $OS [ParcelScripts] $Scripts # base constructor - ParcelPackage([hashtable]$package) + ParcelPackage([hashtable]$_package) { # fail on no name - if ([string]::IsNullOrWhiteSpace($package.name)) { - throw "No name supplied for $($package.provider) package" + if ([string]::IsNullOrWhiteSpace($_package.name)) { + throw "No name supplied for $($_package.provider) package" } # set ensure to default - if ([string]::IsNullOrWhiteSpace($package.ensure)) { - $package.ensure = 'neutral' + if ([string]::IsNullOrWhiteSpace($_package.ensure)) { + $_package.ensure = 'neutral' } # set environment to default - if ([string]::IsNullOrWhiteSpace($package.environment)) { - $package.environment = 'none' + if ([string]::IsNullOrWhiteSpace($_package.environment)) { + $_package.environment = @('all') } # set os to default - if ([string]::IsNullOrWhiteSpace($package.os)) { - $package.os = 'all' + if ([string]::IsNullOrWhiteSpace($_package.os)) { + $_package.os = 'all' } # if version is empty, assume latest - if ([string]::IsNullOrWhiteSpace($package.version)) { - $package.version = 'latest' + if ([string]::IsNullOrWhiteSpace($_package.version)) { + $_package.version = 'latest' } # are we using the latest version? - if ([string]::IsNullOrWhiteSpace($package.version) -or ($package.version -ieq 'latest')) { + if ([string]::IsNullOrWhiteSpace($_package.version) -or ($_package.version -ieq 'latest')) { $this.IsLatest = $true } # set the properties - $this.Name = $package.name - $this.Version = $package.version - $this.ProviderName = $package.provider - $this.Source = $package.source - $this.Arguments = [ParcelArguments]::new($package.args) + $this.Name = $_package.name + $this.Version = $_package.version + $this.ProviderName = $_package.provider + $this.Source = $_package.source + $this.Arguments = [ParcelArguments]::new($_package.args) - $this.Ensure = [ParcelEnsureType]$package.ensure - $this.OS = $package.os - $this.Environment = $package.environment - $this.When = $package.when + $this.Ensure = [ParcelEnsureType]$_package.ensure + $this.OS = $_package.os + $this.Environment = @($_package.environment) + $this.When = $_package.when # set the scripts - $this.Scripts = [ParcelScripts]::new($package.pre, $package.post) + $this.Scripts = [ParcelScripts]::new($_package.pre, $_package.post) } [ParcelStatus] TestPackage([hashtable]$_context) @@ -89,20 +89,16 @@ class ParcelPackage [ParcelStatus] TestEnvironment([string]$_environment) { - if ([string]::IsNullOrWhiteSpace($_environment) -or ('none' -ieq $_environment)) { + if (($this.Environment.Length -eq 0) -or ($this.Environment.Length -eq 1 -and $this.Environment[0] -ieq 'all')) { return $null } - if ([string]::IsNullOrWhiteSpace($this.Environment) -or ('none' -ieq $this.Environment)) { - return $null - } - - $valid = ($_environment -ieq $this.Environment) + $valid = ($this.Environment -icontains $_environment) if ($valid) { return $null } - return [ParcelStatus]::new('Skipped', "Wrong environment [$($this.Environment) =/= $($_environment)]") + return [ParcelStatus]::new('Skipped', "Wrong environment [$($this.Environment -join ', ') =/= $($_environment)]") } [ParcelStatus] TestOS([string]$_os) diff --git a/src/Classes/Providers/AptGetParcelProvider.ps1 b/src/Classes/Providers/AptGetParcelProvider.ps1 new file mode 100644 index 0000000..279a75e --- /dev/null +++ b/src/Classes/Providers/AptGetParcelProvider.ps1 @@ -0,0 +1,55 @@ +class AptGetParcelProvider : ParcelProvider +{ + AptGetParcelProvider() : base('Apt-Get', $false, [string]::Empty) {} + + [bool] TestProviderInstalled([hashtable]$_context) + { + # fail if apt-get isn't available + if ($null -eq (Get-Command -Name 'apt-get' -ErrorAction Ignore)) { + throw 'The provider apt-get is not installed' + } + + return $true + } + + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + return "sudo apt-get install --yes $($_package.Name)=$($this.GetVersionArgument($_package)) 2>&1" + } + + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + return "sudo apt-get remove --purge --yes $($_package.Name) 2>&1" + } + + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + $result = (Invoke-Expression -Command "`$r = dpkg -s $($_package.Name) 2>&1; if (!`$?) { return `$null }; return `$r") + if ($null -eq $result) { + return $false + } + + return (($result -imatch "^version\:\s+$($this.GetVersionArgument($_package))").Length -gt 0) + } + + [bool] TestPackageUninstalled([ParcelPackage]$_package) + { + $result = (Invoke-Expression -Command "`$r = dpkg -s $($_package.Name) 2>&1; if (!`$?) { return `$null }; return `$r") + return ($null -eq $result) + } + + [string] GetPackageLatestVersion([ParcelPackage]$_package) + { + $result = Invoke-Expression -Command "apt-cache madison $($_package.Name) 2>&1" + if (($null -eq $result) -or ($result.Length -eq 0)) { + throw "The $($_package.Name) package was not found on apt-get" + } + + return ($result[0] -split '\|')[1].Trim() + } + + [string] GetVersionArgument([ParcelPackage]$_package) + { + return $_package.Version + } +} \ No newline at end of file diff --git a/src/Classes/Providers/BrewParcelProvider.ps1 b/src/Classes/Providers/BrewParcelProvider.ps1 new file mode 100644 index 0000000..b078825 --- /dev/null +++ b/src/Classes/Providers/BrewParcelProvider.ps1 @@ -0,0 +1,77 @@ +class BrewParcelProvider : ParcelProvider +{ + BrewParcelProvider() : base('Brew', $false, 'brew') {} + + [bool] TestProviderInstalled([hashtable]$_context) + { + $cmd = Get-Command -Name 'brew' -ErrorAction Ignore + return ($null -ne $cmd) + } + + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + $_script = "`$env:HOMEBREW_NO_AUTO_UPDATE = '1'; " + + if ($this.TestIsOnlineCask($_package)) { + if ($_context.os.type -ine 'macos') { + throw "Brew casks are only supported on MacOS" + } + + $_script += "brew cask install --force $($_package.Name)" + } + else { + $_script += "brew install --force $($_package.Name)" + } + + return "$($_script) @PARCEL_NO_VERSION" + } + + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + if ($this.TestIsLocalCask($_package)) { + return "brew cask uninstall --force $($_package.Name)" + } + else { + return "brew uninstall --force $($_package.Name)" + } + } + + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + $result = @(Invoke-Expression -Command "brew list --versions $($_package.Name)") + $result = ($result -imatch "$($_package.Name)\s+$($this.GetVersionArgument($_package))") + return (($result -imatch "$($_package.Name)\s+[0-9\._]+").Length -gt 0) + } + + [bool] TestPackageUninstalled([ParcelPackage]$_package) + { + $result = @(Invoke-Expression -Command "brew list --versions $($_package.Name)") + return (($result -imatch "$($_package.Name)\s+[0-9\._]+").Length -eq 0) + } + + [string] GetPackageLatestVersion([ParcelPackage]$_package) + { + return [string]::Empty + } + + [string] GetVersionArgument([ParcelPackage]$_package) + { + if ($_package.IsLatest) { + return [string]::Empty + } + + return $_package.Version + } + + [bool] TestIsOnlineCask([ParcelPackage]$_package) + { + $result = @(Invoke-Expression -Command "brew search --casks $($_package.Name)") + return ($result[0] -ilike '*casks*') + } + + [bool] TestIsLocalCask([ParcelPackage]$_package) + { + $result = @(Invoke-Expression -Command "brew cask list --versions $($_package.Name) 2>&1") + return (($result -imatch "$($_package.Name)\s+[0-9\._]+").Length -gt 0) + } +} \ No newline at end of file diff --git a/src/Classes/Providers/ChocoProvider.ps1 b/src/Classes/Providers/ChocoParcelProvider.ps1 similarity index 84% rename from src/Classes/Providers/ChocoProvider.ps1 rename to src/Classes/Providers/ChocoParcelProvider.ps1 index c9f08c9..f805a72 100644 --- a/src/Classes/Providers/ChocoProvider.ps1 +++ b/src/Classes/Providers/ChocoParcelProvider.ps1 @@ -2,13 +2,13 @@ class ChocoParcelProvider : ParcelProvider { ChocoParcelProvider() : base('Chocolatey', $false, 'chocolatey') {} - [bool] TestProviderInstalled() + [bool] TestProviderInstalled([hashtable]$_context) { $cmd = Get-Command -Name 'choco' -ErrorAction Ignore return ($null -ne $cmd) } - [scriptblock] GetProviderInstallScriptBlock() + [scriptblock] GetProviderInstallScriptBlock([hashtable]$_context) { return { Set-ExecutionPolicy Bypass -Scope Process -Force | Out-Null @@ -16,12 +16,12 @@ class ChocoParcelProvider : ParcelProvider } } - [string] GetPackageInstallScript([ParcelPackage]$_package) + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) { return "choco install $($_package.Name) --no-progress -y -f --allow-unofficial --allow-downgrade" } - [string] GetPackageUninstallScript([ParcelPackage]$_package) + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) { return "choco uninstall $($_package.Name) --no-progress -y -f -x --allversions" } @@ -52,7 +52,7 @@ class ChocoParcelProvider : ParcelProvider { $result = Invoke-Expression -Command "choco search $($_package.Name) --exact $($this.GetSourceArgument($_package)) --allow-unofficial" - $regex = "$($_package.Name)\s+(?[0-9\.]+)" + $regex = "$($_package.Name)\s+(?[0-9\._]+)" $result = @(@($result) -imatch $regex) if (($result.Length -gt 0) -and ($result[0] -imatch $regex)) { @@ -89,7 +89,7 @@ class ChocoParcelProvider : ParcelProvider [string] GetVersionArgument([ParcelPackage]$_package) { - if ([string]::IsNullOrWhiteSpace($_package.Version) -or ($_package.Version -ieq 'latest')) { + if ($_package.IsLatest) { return [string]::Empty } @@ -100,13 +100,13 @@ class ChocoParcelProvider : ParcelProvider { $_source = $_package.Source if ([string]::IsNullOrWhiteSpace($_source)) { - $_source = $this.DefaultSource + $_source = @($this.DefaultSource) } - if ([string]::IsNullOrWhiteSpace($_source)) { + if ([string]::IsNullOrWhiteSpace($_source[0])) { return [string]::Empty } - return "--source $($_source)" + return "--source $($_source[0])" } } \ No newline at end of file diff --git a/src/Classes/Providers/DockerParcelProvider.ps1 b/src/Classes/Providers/DockerParcelProvider.ps1 new file mode 100644 index 0000000..d35102a --- /dev/null +++ b/src/Classes/Providers/DockerParcelProvider.ps1 @@ -0,0 +1,61 @@ +class DockerParcelProvider : ParcelProvider +{ + DockerParcelProvider() : base('Docker', $false, 'docker') {} + + [bool] TestProviderInstalled([hashtable]$_context) + { + $cmd = Get-Command -Name 'docker' -ErrorAction Ignore + return ($null -ne $cmd) + } + + #TODO: come back here when we can have "GetProviderInstallPackages" + # which can return a number of packages to install. + # this will allow us to install more complicated packages, and see what is being installed. + # also can add a flag on providers of "install: false", which will disable self-install/check + + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + return "docker pull $($_package.Name):$($this.GetVersionArgument($_package))" + } + + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + return "docker rmi --force $($_package.Name)" + } + + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + # always pull down the latest + if ($_package.IsLatest) { + return $false + } + + # get current images + return ($null -ne $this.FindDockerImages($_package)) + } + + [bool] TestPackageUninstalled([ParcelPackage]$_package) + { + return ($null -eq $this.FindDockerImages($_package)) + } + + [string] GetPackageLatestVersion([ParcelPackage]$_package) + { + return 'latest' + } + + [string] GetVersionArgument([ParcelPackage]$_package) + { + if ($_package.IsLatest) { + return 'latest' + } + + return $_package.Version + } + + [object] FindDockerImages([ParcelPackage]$_package) + { + $_images = Invoke-Expression -Command "docker images --format '{{json .}}'" -ErrorAction Stop + return ($_images | ConvertFrom-Json) | Where-Object { ($_.Repository -ieq $_package.Name) -and ($_.Tag -ieq $_package.Version) } + } +} \ No newline at end of file diff --git a/src/Classes/Providers/PSGalleryProvider.ps1 b/src/Classes/Providers/PSGalleryParcelProvider.ps1 similarity index 74% rename from src/Classes/Providers/PSGalleryProvider.ps1 rename to src/Classes/Providers/PSGalleryParcelProvider.ps1 index 7ccdad2..2823d7f 100644 --- a/src/Classes/Providers/PSGalleryProvider.ps1 +++ b/src/Classes/Providers/PSGalleryParcelProvider.ps1 @@ -2,7 +2,7 @@ class PSGalleryParcelProvider : ParcelProvider { PSGalleryParcelProvider() : base('PowerShell Gallery', $false, 'PSGallery') {} - [bool] TestProviderInstalled() + [bool] TestProviderInstalled([hashtable]$_context) { if ((Get-Host).Version.Major -gt '5') { return $true @@ -11,19 +11,19 @@ class PSGalleryParcelProvider : ParcelProvider return ($null -ne (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction Ignore)) } - [scriptblock] GetProviderInstallScriptBlock() + [scriptblock] GetProviderInstallScriptBlock([hashtable]$_context) { return { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null } } - [string] GetPackageInstallScript([ParcelPackage]$_package) + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) { return "Install-Module -Name $($_package.Name) -Force -AllowClobber -SkipPublisherCheck -ErrorAction Stop" } - [string] GetPackageUninstallScript([ParcelPackage]$_package) + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) { return "Uninstall-Module -Name $($_package.Name) -Force -AllVersions -ErrorAction Stop" } @@ -44,6 +44,12 @@ class PSGalleryParcelProvider : ParcelProvider return ($result.Length -gt 0) } + [bool] TestPackageUninstalled([ParcelPackage]$_package) + { + $result = (Get-Module -Name $_package.Name -ListAvailable) + return ($result.Length -eq 0) + } + [string] GetPackageLatestVersion([ParcelPackage]$_package) { return Invoke-Expression -Command "(Find-Module -Name $($_package.Name) $($this.GetSourceArgument($_package)) -ErrorAction Ignore).Version" @@ -51,10 +57,6 @@ class PSGalleryParcelProvider : ParcelProvider [string] GetVersionArgument([ParcelPackage]$_package) { - if ([string]::IsNullOrWhiteSpace($_package.Version) -or ($_package.Version -ieq 'latest')) { - return [string]::Empty - } - return "-RequiredVersion $($_package.Version)" } @@ -62,13 +64,13 @@ class PSGalleryParcelProvider : ParcelProvider { $_source = $_package.Source if ([string]::IsNullOrWhiteSpace($_source)) { - $_source = $this.DefaultSource + $_source = @($this.DefaultSource) } - if ([string]::IsNullOrWhiteSpace($_source)) { + if ([string]::IsNullOrWhiteSpace($_source[0])) { return [string]::Empty } - return "-Repository $($_source)" + return "-Repository $($_source[0])" } } \ No newline at end of file diff --git a/src/Classes/Providers/ParcelProvider.ps1 b/src/Classes/Providers/ParcelProvider.ps1 index f6b0471..7f998cd 100644 --- a/src/Classes/Providers/ParcelProvider.ps1 +++ b/src/Classes/Providers/ParcelProvider.ps1 @@ -19,11 +19,11 @@ class ParcelProvider } # implemented base functions - [ParcelStatus] InstallProvider([bool]$_dryRun) + [ParcelStatus] InstallProvider([hashtable]$_context, [bool]$_dryRun) { # get the scriptblock and invoke it if (!$_dryRun) { - Invoke-Command -ScriptBlock ($this.GetInstallProviderScriptBlock()) -ErrorAction Stop | Out-Null + Invoke-Command -ScriptBlock ($this.GetProviderInstallScriptBlock($_context)) -ErrorAction Stop | Out-Null } # changed @@ -38,6 +38,11 @@ class ParcelProvider return $status } + # check if the provider is installed + if (!$this.TestProviderInstalled($_context)) { + throw "The $($this.Name) provider is not installed" + } + # do nothing if package is already installed if ($this.TestPackageInstalled($_package)) { return [ParcelStatus]::new('Skipped', 'Already installed') @@ -51,18 +56,28 @@ class ParcelProvider try { # get install script - adding args, version and source - $_script = $this.GetPackageInstallScript($_package) + $_script = $this.GetPackageInstallScript($_package, $_context) $_script += " $($_package.Arguments.Install)" $_script += " $($this.Arguments.Install)" - $_version = $this.GetVersionArgument($_package) - if (![string]::IsNullOrWhiteSpace($_version) -and !$_script.Contains($_version)) { - $_script += " $($_version)" + if ($_script -ilike '*@PARCEL_NO_VERSION*') { + $_script = $_script -ireplace '\@PARCEL_NO_VERSION', '' + } + else { + $_version = $this.GetVersionArgument($_package) + if (![string]::IsNullOrWhiteSpace($_version) -and !$_script.Contains($_version)) { + $_script += " $($_version)" + } } - $_source = $this.GetSourceArgument($_package) - if (![string]::IsNullOrWhiteSpace($_source) -and !$_script.Contains($_source)) { - $_script += " $($_source)" + if ($_script -ilike '*@PARCEL_NO_SOURCE*') { + $_script = $_script -ireplace '\@PARCEL_NO_SOURCE', '' + } + else { + $_source = $this.GetSourceArgument($_package) + if (![string]::IsNullOrWhiteSpace($_source) -and !$_script.Contains($_source)) { + $_script += " $($_source)" + } } Write-Verbose $_script @@ -94,6 +109,11 @@ class ParcelProvider [ParcelStatus] Uninstall([ParcelPackage]$_package, [hashtable]$_context, [bool]$_dryRun) { + # check if the provider is installed + if (!$this.TestProviderInstalled($_context)) { + throw "The $($this.Name) provider is not installed" + } + # check if package is valid $status = $_package.TestPackage($_context) if ($null -ne $status) { @@ -113,7 +133,7 @@ class ParcelProvider try { # get uninstall script - adding args - $_script = $this.GetPackageUninstallScript($_package) + $_script = $this.GetPackageUninstallScript($_package, $_context) $_script += " $($_package.Arguments.Uninstall)" $_script += " $($this.Arguments.Uninstall)" @@ -160,7 +180,16 @@ class ParcelProvider $_latestFlag = ' ' } - return "$($_package.Name.ToUpperInvariant()) [v$($_package.Version)$($_latestFlag) - $($this.Name)]" + $_version = [string]::Empty + if (![string]::IsNullOrWhiteSpace($_package.Version) -and ($_package.Version -ine 'latest')) { + $_version = "v$($_package.Version)" + } + + if ([string]::IsNullOrWhiteSpace($_version)) { + $_latestFlag = $_latestFlag.Trim() + } + + return "$($_package.Name.ToUpperInvariant()) [$($_version)$($_latestFlag) - $($this.Name)]" } [bool] TestPackageUninstalled([ParcelPackage]$_package) @@ -214,7 +243,7 @@ class ParcelProvider $_script = $this.GetProviderAddSourceScript($_source.name, $_source.url) Write-Verbose $_script - if (!$_dryRun) { + if (![string]::IsNullOrWhiteSpace($_script) -and !$_dryRun) { if ($this.RunAsPowerShell) { $output = Invoke-ParcelPowershell -Command $_script } @@ -250,7 +279,7 @@ class ParcelProvider $_script = $this.GetProviderRemoveSourceScript($_source.name) Write-Verbose $_script - if (!$_dryRun) { + if (![string]::IsNullOrWhiteSpace($_script) -and !$_dryRun) { if ($this.RunAsPowerShell) { $output = Invoke-ParcelPowershell -Command $_script } @@ -276,7 +305,7 @@ class ParcelProvider # unimplemented base functions - [bool] TestProviderInstalled() + [bool] TestProviderInstalled([hashtable]$_context) { return $true } @@ -296,15 +325,33 @@ class ParcelProvider return [string]::Empty } - [scriptblock] GetProviderInstallScriptBlock() { throw [System.NotImplementedException]::new() } + [scriptblock] GetProviderInstallScriptBlock([hashtable]$_context) + { + throw [System.NotImplementedException]::new("GetProviderInstallScriptBlock ($($this.Name))") + } - [string] GetPackageInstallScript([ParcelPackage]$_package) { throw [System.NotImplementedException]::new() } + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + throw [System.NotImplementedException]::new("GetPackageInstallScript ($($this.Name))") + } - [string] GetPackageUninstallScript([ParcelPackage]$_package) { throw [System.NotImplementedException]::new() } + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + throw [System.NotImplementedException]::new("GetPackageUninstallScript ($($this.Name))") + } - [bool] TestPackageInstalled([ParcelPackage]$_package) { throw [System.NotImplementedException]::new() } + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + throw [System.NotImplementedException]::new("TestPackageInstalled ($($this.Name))") + } - [string] GetProviderAddSourceScript([string]$_name, [string]$_url) { throw [System.NotImplementedException]::new() } + [string] GetProviderAddSourceScript([string]$_name, [string]$_url) + { + throw [System.NotImplementedException]::new("GetProviderAddSourceScript ($($this.Name))") + } - [string] GetProviderRemoveSourceScript([string]$_name, [string]$_url) { throw [System.NotImplementedException]::new() } + [string] GetProviderRemoveSourceScript([string]$_name, [string]$_url) + { + throw [System.NotImplementedException]::new("GetProviderRemoveSourceScript ($($this.Name))") + } } \ No newline at end of file diff --git a/src/Classes/Providers/ScoopProvider.ps1 b/src/Classes/Providers/ScoopParcelProvider.ps1 similarity index 74% rename from src/Classes/Providers/ScoopProvider.ps1 rename to src/Classes/Providers/ScoopParcelProvider.ps1 index b91232d..5305a2e 100644 --- a/src/Classes/Providers/ScoopProvider.ps1 +++ b/src/Classes/Providers/ScoopParcelProvider.ps1 @@ -2,13 +2,13 @@ class ScoopParcelProvider : ParcelProvider { ScoopParcelProvider() : base('Scoop', $true, [string]::Empty) {} - [bool] TestProviderInstalled() + [bool] TestProviderInstalled([hashtable]$_context) { $cmd = Get-Command -Name 'scoop' -ErrorAction Ignore return ($null -ne $cmd) } - [scriptblock] GetProviderInstallScriptBlock() + [scriptblock] GetProviderInstallScriptBlock([hashtable]$_context) { return { Set-ExecutionPolicy RemoteSigned -Scope Process -Force | Out-Null @@ -16,12 +16,17 @@ class ScoopParcelProvider : ParcelProvider } } - [string] GetPackageInstallScript([ParcelPackage]$_package) + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) { - return "scoop install $($_package.Name)@$($this.GetVersionArgument($_package))" + $_version = $this.GetVersionArgument($_package) + if (![string]::IsNullOrWhiteSpace($_version)) { + $_version = "@$($_version)" + } + + return "scoop install $($_package.Name)$($_version)" } - [string] GetPackageUninstallScript([ParcelPackage]$_package) + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) { return "scoop uninstall $($_package.Name) -p" } @@ -39,7 +44,7 @@ class ScoopParcelProvider : ParcelProvider [bool] TestPackageInstalled([ParcelPackage]$_package) { $result = Invoke-ParcelPowershell -Command "scoop list $($_package.Name)" - $result = ($result -imatch "^\s*$($_package.Name)\s+$($this.GetVersionArgument($_package))") + $result = (@($result) -imatch "^\s*$($_package.Name)\s+$($this.GetVersionArgument($_package))") return ((@($result) -imatch "^\s*$($_package.Name)\s+[0-9\._]+").Length -gt 0) } @@ -65,7 +70,7 @@ class ScoopParcelProvider : ParcelProvider [string] GetVersionArgument([ParcelPackage]$_package) { - if ([string]::IsNullOrWhiteSpace($_package.Version) -or ($_package.Version -ieq 'latest')) { + if ($_package.IsLatest) { return [string]::Empty } diff --git a/src/Classes/Providers/WindowsDISMParcelProvider.ps1 b/src/Classes/Providers/WindowsDISMParcelProvider.ps1 new file mode 100644 index 0000000..a6a64ae --- /dev/null +++ b/src/Classes/Providers/WindowsDISMParcelProvider.ps1 @@ -0,0 +1,45 @@ +class WindowsDISMParcelProvider : ParcelProvider +{ + WindowsDISMParcelProvider() : base('Windows DISM', $false, [string]::Empty) {} + + [bool] TestProviderInstalled([hashtable]$_context) + { + if ($_context.os.type -ine 'windows') { + throw 'Windows DISM is only supported on Windows...' + } + + return $true + } + + [string] GetPackageInstallScript([ParcelPackage]$_package) + { + return "Invoke-Expression -Command 'dism /online /enable-feature /all /featurename:$($_package.Name) /norestart'" + } + + [string] GetPackageUninstallScript([ParcelPackage]$_package) + { + return "Invoke-Expression -Command 'dism /online /disable-feature /featurename:$($_package.Name)'" + } + + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + $checkDismPackage = Invoke-Expression -Command "dism /online /get-featureinfo /featurename:$($_package.Name )" -ErrorAction Stop + $checkDismPackageState = $checkDismPackage -imatch "State" + return ($checkDismPackageState -inotlike "*Disabled*") + } + + [string] GetSourceArgument([ParcelPackage]$_package) + { + $_source = $_package.Source + if ([string]::IsNullOrWhiteSpace($_source)) { + $_source = @($this.DefaultSource) + } + + if ([string]::IsNullOrWhiteSpace($_source)) { + return [string]::Empty + } + + return (($_source | ForEach-Object { "/source:$($_)" }) -join ' ') + } + +} \ No newline at end of file diff --git a/src/Classes/Providers/WindowsFeatureParcelProvider.ps1 b/src/Classes/Providers/WindowsFeatureParcelProvider.ps1 new file mode 100644 index 0000000..280e19f --- /dev/null +++ b/src/Classes/Providers/WindowsFeatureParcelProvider.ps1 @@ -0,0 +1,78 @@ +class WindowsFeatureParcelProvider : ParcelProvider +{ + WindowsFeatureParcelProvider() : base('Windows Feature', $false, [string]::Empty) {} + + [bool] TestProviderInstalled([hashtable]$_context) + { + if ($_context.os.type -ine 'windows') { + throw 'Windows Features are only supported on Windows' + } + + if ((Get-Host).Version.Major -gt '5') { + throw "Windows Features is only supported on PS5.0" + } + + return $true + } + + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + if ($this.IsOptionalFeature($_package)) { + return "Enable-WindowsOptionalFeature -FeatureName $($_package.Name) -NoRestart -All -Online -ErrorAction Stop" + } + + return "Add-WindowsFeature -Name $($_package.Name) -IncludeAllSubFeature -IncludeManagementTools -ErrorAction Stop" + } + + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + if ($this.IsOptionalFeature($_package)) { + return "Disable-WindowsOptionalFeature -FeatureName $($_package.Name) -NoRestart -Online -ErrorAction Stop" + } + + return "Remove-WindowsFeature -Name $($_package.Name) -IncludeManagementTools -ErrorAction Stop" + } + + [string] GetProviderAddSourceScript([string]$_name, [string]$_url) + { + return $null + } + + [string] GetProviderRemoveSourceScript([string]$_name, [string]$_url) + { + return $null + } + + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + if ($this.IsOptionalFeature($_package)) { + return ((Get-WindowsOptionalFeature -Online -FeatureName $_package.Name -ErrorAction Ignore).State -ieq 'enabled') + } + + return ([bool](Get-WindowsFeature -Name $_package.Name -ErrorAction Ignore).Installed) + } + + [string] GetSourceArgument([ParcelPackage]$_package) + { + $_source = $_package.Source + if ([string]::IsNullOrWhiteSpace($_source)) { + $_source = @($this.DefaultSource) + } + + if ([string]::IsNullOrWhiteSpace($_source[0])) { + return [string]::Empty + } + + return "-Source $($_source[0])" + } + + [bool] IsOptionalFeature([ParcelPackage]$_package) + { + $optional = $true + if ($null -ne (Get-Command -Name 'Get-WindowsFeature' -ErrorAction Ignore)) { + $optional = ($null -eq (Get-WindowsFeature -Name $_package.Name -ErrorAction Ignore)) + } + + return $optional + } +} \ No newline at end of file diff --git a/src/Classes/Providers/YumParcelProvider.ps1 b/src/Classes/Providers/YumParcelProvider.ps1 new file mode 100644 index 0000000..6232722 --- /dev/null +++ b/src/Classes/Providers/YumParcelProvider.ps1 @@ -0,0 +1,51 @@ +class YumParcelProvider : ParcelProvider +{ + YumParcelProvider() : base('Yum', $false, [string]::Empty) {} + + [bool] TestProviderInstalled([hashtable]$_context) + { + # fail if yum isn't available + if ($null -eq (Get-Command -Name 'yum' -ErrorAction Ignore)) { + throw 'The provider yum is not installed' + } + + return $true + } + + [string] GetPackageInstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + return "sudo yum install -y -q $($_package.Name)-$($this.GetVersionArgument($_package)) 2>&1" + } + + [string] GetPackageUninstallScript([ParcelPackage]$_package, [hashtable]$_context) + { + return "sudo yum remove -y $($_package.Name) 2>&1" + } + + [bool] TestPackageInstalled([ParcelPackage]$_package) + { + $result = Invoke-Expression -Command "yum list installed 2>&1" + return ($null -ne ($result | Select-String -Pattern "^$($_package.Name)\.[\w_\d]+\s+$($this.GetVersionArgument($_package))")) + } + + [bool] TestPackageUninstalled([ParcelPackage]$_package) + { + $result = Invoke-Expression -Command "yum list installed 2>&1" + return ($null -eq ($result | Select-String -Pattern "^$($_package.Name)\.[\w_\d]+\s+")) + } + + [string] GetPackageLatestVersion([ParcelPackage]$_package) + { + $result = Invoke-Expression -Command "yum info $($_package.Name) 2>&1" + if (!$?) { + throw "The $($_package.Name) package was not found on yum" + } + + return (($result | Select-String -Pattern 'version\s+\:\s+.+?')[0] -split '\:')[-1].Trim() + } + + [string] GetVersionArgument([ParcelPackage]$_package) + { + return $_package.Version + } +} \ No newline at end of file diff --git a/src/Parcel.psd1 b/src/Parcel.psd1 index e3a61b9..a5c9739 100644 --- a/src/Parcel.psd1 +++ b/src/Parcel.psd1 @@ -11,7 +11,7 @@ RootModule = 'Parcel.psm1' # Version number of this module. - ModuleVersion = '0.1.0' + ModuleVersion = '0.2.0' # ID used to uniquely identify this module GUID = 'f9c84a02-ce1a-4512-be6e-18cb5bd9a803' diff --git a/src/Parcel.psm1 b/src/Parcel.psm1 index d446221..a4b98c7 100644 --- a/src/Parcel.psm1 +++ b/src/Parcel.psm1 @@ -29,9 +29,15 @@ $classes = @( "$($root)/Classes/ParcelArguments.ps1", "$($root)/Classes/ParcelPackage.ps1", "$($root)/Classes/Providers/ParcelProvider.ps1", - "$($root)/Classes/Providers/ChocoProvider.ps1", - "$($root)/Classes/Providers/PSGalleryProvider.ps1", - "$($root)/Classes/Providers/ScoopProvider.ps1", + "$($root)/Classes/Providers/ChocoParcelProvider.ps1", + "$($root)/Classes/Providers/PSGalleryParcelProvider.ps1", + "$($root)/Classes/Providers/ScoopParcelProvider.ps1", + "$($root)/Classes/Providers/BrewParcelProvider.ps1", + "$($root)/Classes/Providers/DockerParcelProvider.ps1", + "$($root)/Classes/Providers/WindowsFeatureParcelProvider.ps1", + "$($root)/Classes/Providers/WindowsDISMParcelProvider.ps1", + "$($root)/Classes/Providers/AptGetParcelProvider.ps1", + "$($root)/Classes/Providers/YumParcelProvider.ps1", "$($root)/Classes/ParcelFactory.ps1" ) diff --git a/src/Private/Factory.ps1 b/src/Private/Factory.ps1 index 6a96439..34e0dff 100644 --- a/src/Private/Factory.ps1 +++ b/src/Private/Factory.ps1 @@ -58,7 +58,21 @@ function ConvertTo-ParcelPackages # convert each package to a parcel provider $Packages | ForEach-Object { - ConvertTo-ParcelPackage -Package $_ -Context $Context + $_p = $_ + + if ([string]::IsNullOrWhiteSpace($_p.name) -and ($_p.names.Length -gt 0)) { + if (![string]::IsNullOrWhiteSpace($_p.version) -and ($_p.version -ine 'latest')) { + throw "Supplying a version with multiple package names is not supported" + } + + $_p.names | ForEach-Object { + $_p.name = $_ + ConvertTo-ParcelPackage -Package $_p -Context $Context + } + } + else { + ConvertTo-ParcelPackage -Package $_p -Context $Context + } } } @@ -179,7 +193,7 @@ function Get-ParcelContext # set environment $ctx.environment = $Environment if ([string]::IsNullOrWhiteSpace($ctx.environment)) { - $ctx.environment = 'none' + $ctx.environment = 'all' } # return the context @@ -228,7 +242,7 @@ function Invoke-ParcelPackages } # check if we need to install any providers - $stats.Install += [ParcelFactory]::Instance().InstallProviders($WhatIf) + $stats.Install += [ParcelFactory]::Instance().InstallProviders($Context, $WhatIf) # setup any provider details, like sources Initialize-ParcelProviders -Providers $Providers -WhatIf:$WhatIf @@ -309,9 +323,11 @@ function Invoke-ParcelPackages $result.WriteStatusMessage($WhatIf) Write-Host ([string]::Empty) - # refresh the environment and path - Update-ParcelEnvironmentVariables -WhatIf:$WhatIf - Update-ParcelEnvironmentPath -WhatIf:$WhatIf + # refresh the environment and path - for windows + if ($Context.os.type -ieq 'windows') { + Update-ParcelEnvironmentVariables -WhatIf:$WhatIf + Update-ParcelEnvironmentPath -WhatIf:$WhatIf + } } # invoke any global post install/uninstall @@ -461,9 +477,10 @@ function Update-ParcelEnvironmentVariables function Test-ParcelAdminUser { - # check the current platform, if it's unix then return true + # check the current platform, if it's unix then check sudo if ($PSVersionTable.Platform -ieq 'unix') { - return $true + Invoke-Expression -Command 'sudo -n true 2>&1' | Out-Null + return ($LASTEXITCODE -eq 0) } try {