PSDotFiles.psm1
Function Get-DotFiles { <# .SYNOPSIS Enumerates dotfiles components .DESCRIPTION Enumerates the available dotfiles components, where each component is represented by a top-level folder in the folder specified by the $DotFilesPath variable or the -Path parameter. For each component a Component object is constructed and returned which contains the component's basic details, availability, installation state, and other configuration settings. .PARAMETER Path Use the specified directory as the dotfiles directory. This overrides any default specified in $DotFilesPath. .PARAMETER Autodetect Toggles automatic detection of available components without any metadata. This overrides any default specified in $DotFilesAutodetect. If neither is specified the default is disabled. .EXAMPLE PS C:\>Get-DotFiles Enumerates all available dotfiles components and returns a collection of Component objects representing the status of each. .EXAMPLE PS C:\>Get-DotFiles -Autodetect Enumerates all available dotfiles components, attempting automatic detection of those that lack a metadata file, and returns a collection of Component objects representing the status of each. .EXAMPLE PS C:\>$DotFiles = Get-DotFiles Enumerates all available dotfiles components and saves the returned collection of Component objects to the $DotFiles variable. This allows easy inspection and custom formatting of the Component objects. .LINK https://github.com/ralish/PSDotFiles #> [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='Low')] Param( [Parameter(Position=0,Mandatory=$false)] [String]$Path, [Parameter(Mandatory=$false)] [Switch]$Autodetect ) Initialize-PSDotFiles @PSBoundParameters $Components = @() $Directories = Get-ChildItem $script:DotFilesPath -Directory foreach ($Directory in $Directories) { $Component = Get-DotFilesComponent -Directory $Directory if ($Component.Availability -in ('Available', 'AlwaysInstall')) { $Results = Install-DotFilesComponentDirectory -Component $Component -Directories $Component.SourcePath -TestOnly -Silent $Component.State = Get-ComponentInstallResult $Results } $Components += $Component } return $Components } Function Install-DotFiles { <# .SYNOPSIS Installs dotfiles components .DESCRIPTION Installs all available dotfiles components, or the nominated subset provided via a collection of Component objects as previously returned by the Get-DotFiles cmdlet. For each installed component a Component object is returned which contains the component's basic details, availability, installation state, and other configuration settings. .PARAMETER Path Use the specified directory as the dotfiles directory. This parameter is only used when not providing a collection of Component objects as the input, as in this case, the path of each Component is already provided in the object. This overrides any default specified in $DotFilesPath. .PARAMETER Autodetect Toggles automatic detection of available components without any metadata. This parameter is only used when not providing a collection of Component objects as the input, as in this case, the availability of each Component is already provided in the object. This overrides any default specified in $DotFilesAutodetect. If neither is specified the default is disabled. .PARAMETER Components A collection of Component objects to be installed as previously returned by Get-DotFiles. The collection may be a filtered set of Components to ensure only a desired subset is installed. Note that only the Component objects with an appropriate Availability state will be installed. .EXAMPLE PS C:\>Install-DotFiles Installs all available dotfiles components and returns a collection of Component objects representing the status of each. .EXAMPLE PS C:\>Install-DotFiles -Autodetect Installs all available dotfiles components, attempting automatic detection of those that lack a metadata file, and returns a collection of Component objects representing the status of each. .EXAMPLE PS C:\>$Components = Get-DotFiles | ? { $_.Name -eq 'git' -or $_.Name -eq 'vim' } PS C:\>Install-DotFiles -Components $Components Installs only the 'git' and 'vim' dotfiles components, as provided by a filtered set of the components returned by Get-DotFiles, and stored in the $Components variable. .LINK https://github.com/ralish/PSDotFiles #> [CmdletBinding(DefaultParameterSetName='Retrieve',SupportsShouldProcess=$true,ConfirmImpact='Low')] Param( [Parameter(ParameterSetName='Retrieve',Position=0,Mandatory=$false)] [String]$Path, [Parameter(ParameterSetName='Retrieve',Mandatory=$false)] [Switch]$Autodetect, [Parameter(ParameterSetName='Provided',Position=0,Mandatory=$false)] [Component[]]$Components ) if (!(Test-IsAdministrator)) { if ($PSBoundParameters.ContainsKey('WhatIf')) { Write-Warning "Not running with Administrator privileges but ignoring due to -WhatIf." } else { throw "Unable to run Install-DotFiles as not running with Administrator privileges." } } if ($PSCmdlet.ParameterSetName -eq 'Retrieve') { $Components = Get-DotFiles @PSBoundParameters | ? { $_.Availability -in ('Available', 'AlwaysInstall') } } else { $UnfilteredComponents = $Components $Components = $UnfilteredComponents | ? { $_.Availability -in ('Available', 'AlwaysInstall') } } foreach ($Component in $Components) { $Name = $Component.Name if ($PSCmdlet.ShouldProcess($Name, 'Install-DotFilesComponent')) { Write-Verbose ("[$Name] Installing...") } else { Write-Verbose ("[$Name] Simulating install...") $Simulate = $true } Write-Debug ("[$Name] Source directory is: " + $Component.SourcePath) Write-Debug ("[$Name] Installation path is: " + $Component.InstallPath) if (!$Simulate) { $Results = Install-DotFilesComponentDirectory -Component $Component -Directories $Component.SourcePath } else { $Results = Install-DotFilesComponentDirectory -Component $Component -Directories $Component.SourcePath -TestOnly } $Component.State = Get-ComponentInstallResult $Results } return $Components } Function Remove-DotFiles { <# .SYNOPSIS Removes dotfiles components .DESCRIPTION Removes all installed dotfiles components, or the nominated subset provided via a collection of Component objects as previously returned by the Get-DotFiles cmdlet. For each removed component a Component object is returned which contains the component's basic details, availability, installation state, and other configuration settings. .PARAMETER Path Use the specified directory as the dotfiles directory. This parameter is only used when not providing a collection of Component objects as the input, as in this case, the path of each Component is already provided in the object. This overrides any default specified in $DotFilesPath. .PARAMETER Autodetect Toggles automatic detection of available components without any metadata. This parameter is only used when not providing a collection of Component objects as the input, as in this case, the availability of each Component is already provided in the object. This overrides any default specified in $DotFilesAutodetect. If neither is specified the default is disabled. .PARAMETER Components A collection of Component objects to be removed as previously returned by Get-DotFiles. The collection may be a filtered set of Components to ensure only a desired subset is removed. Note that only the Component objects with an appropriate Installed state will be removed. .EXAMPLE PS C:\>Remove-DotFiles Removes all installed dotfiles components and returns a collection of Component objects representing the status of each. .EXAMPLE PS C:\>Remove-DotFiles -Autodetect Removes all available dotfiles components, attempting automatic detection of those that lack a metadata file, and returns a collection of Component objects representing the status of each. .EXAMPLE PS C:\>$Components = Get-DotFiles | ? { $_.Name -eq 'git' -or $_.Name -eq 'vim' } PS C:\>Remove-DotFiles -Components $Components Removes only the 'git' and 'vim' dotfiles components, as provided by a filtered set of the components returned by Get-DotFiles, and stored in the $Components variable. .LINK https://github.com/ralish/PSDotFiles #> [CmdletBinding(DefaultParameterSetName='Retrieve',SupportsShouldProcess=$true,ConfirmImpact='Low')] Param( [Parameter(ParameterSetName='Retrieve',Position=0,Mandatory=$false)] [String]$Path, [Parameter(ParameterSetName='Retrieve',Mandatory=$false)] [Switch]$Autodetect, [Parameter(ParameterSetName='Provided',Position=0,Mandatory=$false)] [Component[]]$Components ) if ($PSCmdlet.ParameterSetName -eq 'Retrieve') { $Components = Get-DotFiles @PSBoundParameters | ? { $_.State -in ('Installed', 'PartialInstall') } } else { $UnfilteredComponents = $Components $Components = $UnfilteredComponents | ? { $_.State -in ('Installed', 'PartialInstall') } } foreach ($Component in $Components) { $Name = $Component.Name if ($PSCmdlet.ShouldProcess($Name, 'Remove-DotFilesComponent')) { Write-Verbose ("[$Name] Removing...") } else { Write-Verbose ("[$Name] Simulating removal...") $Simulate = $true } Write-Debug ("[$Name] Source directory is: " + $Component.SourcePath) Write-Debug ("[$Name] Installation path is: " + $Component.InstallPath) if (!$Simulate) { $Results = Remove-DotFilesComponentDirectory -Component $Component -Directories $Component.SourcePath } else { $Results = Remove-DotFilesComponentDirectory -Component $Component -Directories $Component.SourcePath -TestOnly } $Component.State = Get-ComponentInstallResult $Results -Removal } return $Components } Function Initialize-PSDotFiles { # This function is intentionally *not* an advanced function so that unknown # parameters passed into it via @PSBoundParameters won't cause it to fail. # Do not insert a CmdletBinding() or any Parameter[] attributes or it will # be designated an advanced function (implicitly in the latter case). The # only alternative is to explicitly define all possible parameters which # could be passed into this function via @PSBoundParameters, most of which # won't ever actually be used here. Param( [Switch]$Autodetect, [String]$Path ) if ($Path) { $script:DotFilesPath = Test-DotFilesPath $Path if (!$script:DotFilesPath) { throw "The provided dotfiles path is either not a directory or it can't be accessed." } } elseif ($global:DotFilesPath) { $script:DotFilesPath = Test-DotFilesPath $global:DotFilesPath if (!$script:DotFilesPath) { throw "The default dotfiles path (`$DotFilesPath) is either not a directory or it can't be accessed." } } else { throw "No dotfiles path was provided and the default dotfiles path (`$DotFilesPath) has not been configured." } Write-Verbose "Using dotfiles directory: $script:DotFilesPath" $script:GlobalMetadataPath = Join-Path $PSScriptRoot 'metadata' Write-Debug "Using global metadata directory: $script:GlobalMetadataPath" $script:DotFilesMetadataPath = Join-Path $script:DotFilesPath 'metadata' Write-Debug "Using dotfiles metadata directory: $script:DotFilesMetadataPath" if ($PSBoundParameters.ContainsKey('Autodetect')) { $script:DotFilesAutodetect = $Autodetect } elseif (Get-Variable -Name DotFilesAutodetect -Scope Global -ErrorAction SilentlyContinue | Out-Null) { $script:DotFilesAutodetect = $global:DotFilesAutodetect } else { $script:DotFilesAutodetect = $false } Write-Debug "Automatic component detection state: $script:DotFilesAutodetect" Write-Debug "Refreshing cache of installed programs..." $script:InstalledPrograms = Get-InstalledPrograms } Function Find-DotFilesComponent { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [String]$Name, [Parameter(Mandatory=$false)] [String]$Pattern = "*$Name*", [Parameter(Mandatory=$false)] [Switch]$CaseSensitive, [Parameter(Mandatory=$false)] [Switch]$RegularExpression ) $MatchingParameters = @{'Property'='DisplayName'; 'Value'=$Pattern} if (!$CaseSensitive -and !$RegularExpression) { $MatchingParameters += @{'ILike'=$true} } elseif (!$CaseSensitive -and $RegularExpression) { $MatchingParameters += @{'IMatch'=$true} } elseif ($CaseSensitive -and !$RegularExpression) { $MatchingParameters += @{'CLike'=$true} } else { $MatchingParameters += @{'CMatch'=$true} } $MatchingPrograms = $script:InstalledPrograms | Where-Object @MatchingParameters if ($MatchingPrograms) { return $MatchingPrograms } return $false } Function Get-ComponentInstallResult { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [AllowNull()] [Boolean[]]$Results, [Parameter(Mandatory=$false)] [Switch]$Removal ) if ($Results) { $TotalResults = ($Results | measure).Count $SuccessCount = ($Results | ? { $_ -eq $true } | measure).Count $FailureCount = ($Results | ? { $_ -eq $false } | measure).Count if ($SuccessCount -eq $TotalResults) { if (!$Removal) { return [InstallState]::Installed } else { return [InstallState]::NotInstalled } } elseif ($FailureCount -eq $TotalResults) { if (!$Removal) { return [InstallState]::NotInstalled } else { return [InstallState]::Installed } } else { return [InstallState]::PartialInstall } } return [InstallState]::Unknown } Function Get-DotFilesComponent { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [System.IO.DirectoryInfo]$Directory ) $Name = $Directory.Name $MetadataFile = $Name + '.xml' $GlobalMetadataFile = Join-Path $script:GlobalMetadataPath $MetadataFile $CustomMetadataFile = Join-Path $script:DotFilesMetadataPath $MetadataFile $GlobalMetadataPresent = Test-Path $GlobalMetadataFile -PathType Leaf $CustomMetadataPresent = Test-Path $CustomMetadataFile -PathType Leaf if ($GlobalMetadataPresent -or $CustomMetadataPresent) { if ($GlobalMetadataPresent) { Write-Debug "[$Name] Loading global metadata for component..." $Metadata = [Xml](Get-Content $GlobalMetadataFile) $Component = Initialize-DotFilesComponent -Name $Name -Metadata $Metadata } if ($CustomMetadataPresent) { $Metadata = [Xml](Get-Content $CustomMetadataFile) if ($GlobalMetadataPresent) { Write-Debug "[$Name] Loading custom metadata overrides for component..." $Component = Initialize-DotFilesComponent -Component $Component -Metadata $Metadata } else { Write-Debug "[$Name] Loading custom metadata for component..." $Component = Initialize-DotFilesComponent -Name $Name -Metadata $Metadata } } } elseif ($script:DotFilesAutodetect) { Write-Debug "[$Name] Running automatic detection for component..." $Component = Initialize-DotFilesComponent -Name $Name } else { Write-Debug "[$Name] No metadata & automatic detection disabled." $Component = [Component]::new($Name, $script:DotFilesPath) $Component.Availability = [Availability]::NoLogic } $Component.PSObject.TypeNames.Insert(0, 'PSDotFiles.Component') return $Component } Function Get-InstalledPrograms { [CmdletBinding()] Param() $NativeRegPath = '\Software\Microsoft\Windows\CurrentVersion\Uninstall' $Wow6432RegPath = '\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' $InstalledPrograms = @( # Native applications installed system wide if (Test-Path "HKLM:$NativeRegPath") { Get-ChildItem "HKLM:$NativeRegPath" } # Native applications installed under the current user if (Test-Path "HKCU:$NativeRegPath") { Get-ChildItem "HKCU:$NativeRegPath" } # 32-bit applications installed system wide on 64-bit Windows if (Test-Path "HKLM:$Wow6432RegPath") { Get-ChildItem "HKLM:$Wow6432RegPath" } # 32-bit applications installed under the current user on 64-bit Windows if (Test-Path "HKCU:$Wow6432RegPath") { Get-ChildItem "HKCU:$Wow6432RegPath" } ) | # Get the properties of each uninstall key % { Get-ItemProperty $_.PSPath } | # Filter out all the uninteresting entries ? { $_.DisplayName -and !$_.SystemComponent -and !$_.ReleaseType -and !$_.ParentKeyName -and ($_.UninstallString -or $_.NoRemove) } Write-Debug ("Registry scan found " + ($InstalledPrograms | measure).Count + " installed programs.") return $InstalledPrograms } Function Get-SymlinkTarget { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [System.IO.FileSystemInfo]$Symlink ) if ($Symlink.LinkType -ne 'SymbolicLink') { return $false } $Absolute = [System.IO.Path]::IsPathRooted($Symlink.Target[0]) if ($Absolute) { return $Symlink.Target[0] } else { return (Resolve-Path (Join-Path (Split-Path $Symlink -Parent) $Symlink.Target[0])).Path } } Function Initialize-DotFilesComponent { [CmdletBinding()] Param( [Parameter(ParameterSetName='New',Mandatory=$true)] [String]$Name, [Parameter(ParameterSetName='Override',Mandatory=$true)] [Component]$Component, [Parameter(ParameterSetName='New',Mandatory=$false)] [Parameter(ParameterSetName='Override',Mandatory=$true)] [Xml]$Metadata ) if ($PSCmdlet.ParameterSetName -eq 'New') { $Component = [Component]::new($Name, $script:DotFilesPath) } else { $Name = $Component.Name } # Minimal check for sane XML file if ($PSBoundParameters.ContainsKey('Metadata')) { if (!$Metadata.Component) { $Component.Availability = [Availability]::DetectionFailure Write-Error "[$Name] No <Component> element in metadata file." return $Component } } # Set the friendly name if provided if ($Metadata.Component.FriendlyName) { $Component.FriendlyName = $Metadata.Component.Friendlyname } # Configure and perform component detection if ($Metadata.Component.Detection.Method -eq 'Automatic' -or ($PSCmdlet.ParameterSetName -eq 'New' -and !$Metadata.Component.Detection.Method)) { $Parameters = @{'Name'=$Name} if (!$Metadata.Component.Detection.MatchRegEx -or $Metadata.Component.Detection.MatchRegEx -eq 'False') { $Parameters += @{'RegularExpression'=$false} } elseif ($Metadata.Component.Detection.MatchRegEx -eq 'True') { $Parameters += @{'RegularExpression'=$true} } else { Write-Error ("[$Name] Invalid MatchRegEx setting for automatic component detection: " + $Metadata.Component.Detection.MatchRegEx) } if (!$Metadata.Component.Detection.MatchCase -or $Metadata.Component.Detection.MatchCase -eq 'False') { $Parameters += @{'CaseSensitive'=$false} } elseif ($Metadata.Component.Detection.MatchCase -eq 'True') { $Parameters += @{'CaseSensitive'=$true} } else { Write-Error ("[$Name] Invalid MatchCase setting for automatic component detection: " + $Metadata.Component.Detection.MatchCase) } if ($Metadata.Component.Detection.MatchPattern) { $MatchPattern = $Metadata.Component.Detection.MatchPattern $Parameters += @{'Pattern'=$MatchPattern} } $MatchingPrograms = Find-DotFilesComponent @Parameters if ($MatchingPrograms) { $NumMatchingPrograms = ($MatchingPrograms | measure).Count if ($NumMatchingPrograms -eq 1) { $Component.Availability = [Availability]::Available $Component.UninstallKey = $MatchingPrograms.PSPath if (!$Component.FriendlyName -and $MatchingPrograms.DisplayName) { $Component.FriendlyName = $MatchingPrograms.DisplayName } } elseif ($NumMatchingPrograms -gt 1) { Write-Error "[$Name] Automatic detection found $NumMatchingPrograms matching programs." } } else { $Component.Availability = [Availability]::Unavailable } } elseif ($Metadata.Component.Detection.Method -eq 'FindInPath') { if ($Metadata.Component.Detection.FindInPath) { $FindBinary = $Metadata.Component.Detection.FindInPath } else { $FindBinary = $Component.Name } if (Get-Command $FindBinary -ErrorAction SilentlyContinue) { $Component.Availability = [Availability]::Available } else { $Component.Availability = [Availability]::Unavailable } } elseif ($Metadata.Component.Detection.Method -eq 'PathExists') { if ($Metadata.Component.Detection.PathExists) { if (Test-Path $Metadata.Component.Detection.PathExists) { $Component.Availability = [Availability]::Available } else { $Component.Availability = [Availability]::Unavailable } } else { Write-Error "[$Name] No absolute path specified for testing component availability." } } elseif ($Metadata.Component.Detection.Method -eq 'Static') { if ($Metadata.Component.Detection.Availability) { $Availability = $Metadata.Component.Detection.Availability $Component.Availability = [Availability]::$Availability } else { Write-Error "[$Name] No component availability state specified for static detection." } } elseif ($Metadata.Component.Detection.Method) { Write-Error ("[$Name] Invalid component detection method specified: " + $Metadata.Component.Detection.Method) } # If the component isn't available don't bother determining the install path if ($Component.Availability -notin ('Available', 'AlwaysInstall')) { return $Component } # Configure component installation path if ($PSCmdlet.ParameterSetName -eq 'New' -and !$Metadata.Component.InstallPath.SpecialFolder -and !$Metadata.Component.InstallPath.Destination) { $Component.InstallPath = [Environment]::GetFolderPath('UserProfile') } elseif ($Metadata.Component.InstallPath.SpecialFolder -or $Metadata.Component.InstallPath.Destination) { $SpecialFolder = $Metadata.Component.InstallPath.SpecialFolder $Destination = $Metadata.Component.InstallPath.Destination if (!$SpecialFolder -and !$Destination) { $Component.InstallPath = [Environment]::GetFolderPath('UserProfile') } elseif (!$SpecialFolder -and $Destination) { if ([System.IO.Path]::IsPathRooted($Destination)) { if (Test-Path $Destination -PathType Container -IsValid) { $Component.InstallPath = $Destination } else { Write-Error "[$Name] The destination path for symlinking is invalid: $Destination" } } else { Write-Error "[$Name] The destination path for symlinking is not an absolute path: $Destination" } } elseif ($SpecialFolder -and !$Destination) { $Component.InstallPath = [Environment]::GetFolderPath($SpecialFolder) } else { if (!([System.IO.Path]::IsPathRooted($Destination))) { $InstallPath = Join-Path ([Environment]::GetFolderPath($SpecialFolder)) $Destination if (Test-Path $InstallPath -PathType Container -IsValid) { $Component.InstallPath = $InstallPath } else { Write-Error "[$Name] The destination path for symlinking is invalid: $InstallPath" } } else { Write-Error "[$Name] The destination path for symlinking is not a relative path: $Destination" } } } # Configure component symlink hiding if ($Metadata.Component.InstallPath.HideSymlinks) { $HideSymlinks = $Metadata.Component.InstallPath.HideSymlinks if ($HideSymlinks -eq 'True') { $Component.HideSymlinks = $true } elseif ($HideSymlinks -notin ('True', 'False')) { Write-Error "[$Name] Invalid HideSymlinks setting: $HideSymlinks" } } # Configure component ignore paths if ($Metadata.Component.IgnorePaths.Path) { foreach ($Path in $Metadata.Component.IgnorePaths.Path) { $Component.IgnorePaths += $Path } } return $Component } Function Install-DotFilesComponentDirectory { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [Component]$Component, [Parameter(Mandatory=$true)] [System.IO.DirectoryInfo[]]$Directories, [Parameter(Mandatory=$false)] [Switch]$TestOnly, [Parameter(Mandatory=$false)] [Switch]$Silent ) $Name = $Component.Name $SourcePath = $Component.SourcePath $InstallPath = $Component.InstallPath [Boolean[]]$Results = @() foreach ($Directory in $Directories) { if ($Directory.FullName -eq $SourcePath.FullName) { $TargetDirectory = $InstallPath } else { $SourceDirectoryRelative = $Directory.FullName.Substring($SourcePath.FullName.Length + 1) $TargetDirectory = Join-Path $InstallPath $SourceDirectoryRelative if ($SourceDirectoryRelative -in $Component.IgnorePaths) { if (!$Silent) { Write-Verbose "[$Name] Ignoring directory path: $SourceDirectoryRelative" } continue } } if (Test-Path $TargetDirectory) { $ExistingTarget = Get-Item $TargetDirectory -Force if ($ExistingTarget -isnot [System.IO.DirectoryInfo]) { if (!$Silent) { Write-Error "[$Name] Expected a directory but found a file with the same name: $TargetDirectory" } $Results += $false } elseif ($ExistingTarget.LinkType -eq 'SymbolicLink') { $SymlinkTarget = Get-SymlinkTarget -Symlink $ExistingTarget if (!($Directory.FullName -eq $SymlinkTarget)) { if (!$Silent) { Write-Error "[$Name] Symlink already exists but points to unexpected target: `"$TargetDirectory`" -> `"$SymlinkTarget`"" } $Results += $false } else { if (!$Silent) { Write-Debug "[$Name] Symlink already exists and points to expected target: `"$TargetDirectory`" -> `"$SymlinkTarget`"" } $Results += $true } } else { $NextFiles = Get-ChildItem $Directory.FullName -File -Force if ($NextFiles) { if (!$TestOnly -and !$Silent) { $Results += Install-DotFilesComponentFile -Component $Component -Files $NextFiles } elseif (!$TestOnly -and $Silent) { $Results += Install-DotFilesComponentFile -Component $Component -Files $NextFiles -Silent } elseif ($TestOnly -and !$Silent) { $Results += Install-DotFilesComponentFile -Component $Component -Files $NextFiles -TestOnly } else { $Results += Install-DotFilesComponentFile -Component $Component -Files $NextFiles -TestOnly -Silent } } $NextDirectories = Get-ChildItem $Directory.FullName -Directory -Force if ($NextDirectories) { if (!$TestOnly -and !$Silent) { $Results += Install-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories } elseif (!$TestOnly -and $Silent) { $Results += Install-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories -Silent } elseif ($TestOnly -and !$Silent) { $Results += Install-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories -TestOnly } else { $Results += Install-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories -TestOnly -Silent } } } } else { if (!$Silent) { Write-Verbose ("[$Name] Linking directory: `"$TargetDirectory`" -> `"" + $Directory.FullName + "`"") if ($TestOnly) { New-Item -ItemType SymbolicLink -Path $TargetDirectory -Value $Directory.FullName -WhatIf } else { $Symlink = New-Item -ItemType SymbolicLink -Path $TargetDirectory -Value $Directory.FullName if ($Component.HideSymlinks) { if (!$Silent) { Write-Debug "[$Name] Setting attributes to hide directory symlink: `"$TargetDirectory`"" } $Attributes = Set-SymlinkAttributes -Symlink $Symlink if (!$Attributes) { Write-Error "[$Name] Unable to set Hidden and System attributes on directory symlink: `"$TargetDirectory`"" } } } } $Results += $true } } return $Results } Function Install-DotFilesComponentFile { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [Component]$Component, [Parameter(Mandatory=$true)] [System.IO.FileInfo[]]$Files, [Parameter(Mandatory=$false)] [Switch]$TestOnly, [Parameter(Mandatory=$false)] [Switch]$Silent ) $Name = $Component.Name $SourcePath = $Component.SourcePath $InstallPath = $Component.InstallPath [Boolean[]]$Results = @() foreach ($File in $Files) { $SourceFileRelative = $File.FullName.Substring($SourcePath.FullName.Length + 1) $TargetFile = Join-Path $Component.InstallPath $SourceFileRelative if ($SourceFileRelative -in $Component.IgnorePaths) { if (!$Silent) { Write-Verbose "[$Name] Ignoring file path: $SourceFileRelative" } continue } if (Test-Path $TargetFile) { $ExistingTarget = Get-Item $TargetFile -Force if ($ExistingTarget -isnot [System.IO.FileInfo]) { if (!$Silent) { Write-Error "[$Name] Expected a file but found a directory with the same name: $TargetFile" } $Results += $false } elseif ($ExistingTarget.LinkType -ne 'SymbolicLink') { if (!$Silent) { Write-Error "[$Name] Unable to create symlink as a file with the same name already exists: $TargetFile" } $Results += $false } else { $SymlinkTarget = Get-SymlinkTarget -Symlink $ExistingTarget if (!($File.FullName -eq $SymlinkTarget)) { if (!$Silent) { Write-Error "[$Name] Symlink already exists but points to unexpected target: `"$TargetFile`" -> `"$SymlinkTarget`"" } $Results += $false } else { if (!$Silent) { Write-Debug "[$Name] Symlink already exists and points to expected target: `"$TargetFile`" -> `"$SymlinkTarget`"" } $Results += $true } } } else { if (!$Silent) { Write-Verbose ("[$Name] Linking file: `"$TargetFile`" -> `"" + $File.FullName + "`"") if ($TestOnly) { New-Item -ItemType SymbolicLink -Path $TargetFile -value $File.FullName -WhatIf } else { $Symlink = New-Item -ItemType SymbolicLink -Path $TargetFile -Value $File.FullName if ($Component.HideSymlinks) { if (!$Silent) { Write-Debug "[$Name] Setting attributes to hide file symlink: `"$TargetFile`"" } $Attributes = Set-SymlinkAttributes -Symlink $Symlink if (!$Attributes) { Write-Error "[$Name] Unable to set Hidden and System attributes on file symlink: `"$TargetFile`"" } } } } $Results += $true } } return $Results } Function Remove-DotFilesComponentDirectory { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [Component]$Component, [Parameter(Mandatory=$true)] [System.IO.DirectoryInfo[]]$Directories, [Parameter(Mandatory=$false)] [Switch]$TestOnly, [Parameter(Mandatory=$false)] [Switch]$Silent ) $Name = $Component.Name $SourcePath = $Component.SourcePath $InstallPath = $Component.InstallPath [Boolean[]]$Results = @() foreach ($Directory in $Directories) { if ($Directory.FullName -eq $SourcePath.FullName) { $TargetDirectory = $InstallPath } else { $SourceDirectoryRelative = $Directory.FullName.Substring($SourcePath.FullName.Length + 1) $TargetDirectory = Join-Path $InstallPath $SourceDirectoryRelative if ($SourceDirectoryRelative -in $Component.IgnorePaths) { if (!$Silent) { Write-Verbose "[$Name] Ignoring directory path: $SourceDirectoryRelative" } continue } } if (Test-Path $TargetDirectory) { $ExistingTarget = Get-Item $TargetDirectory -Force if ($ExistingTarget -isnot [System.IO.DirectoryInfo]) { if (!$Silent) { Write-Warning "[$Name] Expected a directory but found a file with the same name: $TargetDirectory" } } elseif ($ExistingTarget.LinkType -eq 'SymbolicLink') { $SymlinkTarget = Get-SymlinkTarget -Symlink $ExistingTarget if (!($Directory.FullName -eq $SymlinkTarget)) { if (!$Silent) { Write-Error "[$Name] Symlink already exists but points to unexpected target: `"$TargetDirectory`" -> `"$SymlinkTarget`"" } $Results += $false } else { if (!$Silent) { Write-Verbose ("[$Name] Removing directory symlink: `"$TargetDirectory`" -> `"" + $Directory.FullName + "`"") if ($TestOnly) { Write-Warning "Will remove directory symlink using native rmdir: $TargetDirectory" } else { $Attributes = Set-SymlinkAttributes -Symlink $ExistingTarget -Remove if (!$Attributes) { Write-Error "[$Name] Unable to remove Hidden and System attributes on directory symlink: `"$TargetDirectory`"" } # Apparently despite PowerShell 5.0's new symlink support you can't # remove a directory symlink without recursively deleting its contents! cmd /c "rmdir `"$TargetDirectory`"" | Out-Null } } $Results += $true } } else { $NextFiles = Get-ChildItem $Directory.FullName -File -Force if ($NextFiles) { if (!$TestOnly -and !$Silent) { $Results += Remove-DotFilesComponentFile -Component $Component -Files $NextFiles } elseif (!$TestOnly -and $Silent) { $Results += Remove-DotFilesComponentFile -Component $Component -Files $NextFiles -Silent } elseif ($TestOnly -and !$Silent) { $Results += Remove-DotFilesComponentFile -Component $Component -Files $NextFiles -TestOnly } else { $Results += Remove-DotFilesComponentFile -Component $Component -Files $NextFiles -TestOnly -Silent } } $NextDirectories = Get-ChildItem $Directory.FullName -Directory -Force if ($NextDirectories) { if (!$TestOnly -and !$Silent) { $Results += Remove-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories } elseif (!$TestOnly -and $Silent) { $Results += Remove-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories -Silent } elseif ($TestOnly -and !$Silent) { $Results += Remove-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories -TestOnly } else { $Results += Remove-DotFilesComponentDirectory -Component $Component -Directories $NextDirectories -TestOnly -Silent } } } } else { if (!$Silent) { Write-Warning "[$Name] Expected a directory but found nothing: $TargetDirectory" } } } return $Results } Function Remove-DotFilesComponentFile { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [Component]$Component, [Parameter(Mandatory=$true)] [System.IO.FileInfo[]]$Files, [Parameter(Mandatory=$false)] [Switch]$TestOnly, [Parameter(Mandatory=$false)] [Switch]$Silent ) $Name = $Component.Name $SourcePath = $Component.SourcePath $InstallPath = $Component.InstallPath [Boolean[]]$Results = @() foreach ($File in $Files) { $SourceFileRelative = $File.FullName.Substring($SourcePath.FullName.Length + 1) $TargetFile = Join-Path $Component.InstallPath $SourceFileRelative if ($SourceFileRelative -in $Component.IgnorePaths) { if (!$Silent) { Write-Verbose "[$Name] Ignoring file path: $SourceFileRelative" } continue } if (Test-Path $TargetFile) { $ExistingTarget = Get-Item $TargetFile -Force if ($ExistingTarget -isnot [System.IO.FileInfo]) { if (!$Silent) { Write-Warning "[$Name] Expected a file but found a directory with the same name: $TargetFile" } } elseif ($ExistingTarget.LinkType -ne 'SymbolicLink') { if (!$Silent) { Write-Warning "[$Name] Found a file instead of a symbolic link so not removing: $TargetFile" } } else { $SymlinkTarget = Get-SymlinkTarget -Symlink $ExistingTarget if (!($File.FullName -eq $SymlinkTarget)) { if (!$Silent) { Write-Error "[$Name] Symlink already exists but points to unexpected target: `"$TargetFile`" -> `"$SymlinkTarget`"" } $Results += $false } else { if (!$Silent) { Write-Verbose ("[$Name] Removing file symlink: `"$TargetFile`" -> `"" + $File.FullName + "`"") if ($TestOnly){ Remove-Item $TargetFile -WhatIf } else { Remove-Item $TargetFile -Force } } $Results += $true } } } else { if (!$Silent) { Write-Warning "[$Name] Expected a file but found nothing: $TargetFile" } } } return $Results } Function Set-SymlinkAttributes { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [System.IO.FileSystemInfo]$Symlink, [Parameter(Mandatory=$false)] [Switch]$Remove ) if ($Symlink.LinkType -ne 'SymbolicLink') { return $false } $HiddenAttribute = [System.IO.FileAttributes]::Hidden $SystemAttribute = [System.IO.FileAttributes]::System if (!$Remove) { $Symlink.Attributes = ($Symlink.Attributes -bor $HiddenAttribute) $Symlink.Attributes = ($Symlink.Attributes -bor $SystemAttribute) } else { if ($CurrentAttributes -band $SystemAttribute) { $Symlink.Attributes = ($CurrentAttributes -bxor $SystemAttribute) } if ($CurrentAttributes -band $HiddenAttribute) { $Symlink.Attributes = ($CurrentAttributes -bxor $HiddenAttribute) } } return $true } Function Test-DotFilesPath { [CmdletBinding()] Param( [Parameter(Mandatory=$true)] [String]$Path ) if (Test-Path $Path) { $PathItem = Get-Item $Path -Force if ($PathItem -is [System.IO.DirectoryInfo]) { $PathLink = Get-SymlinkTarget -Symlink $PathItem if ($PathLink) { return (Test-DotFilesPath $PathLink) } return $PathItem } } return $false } Function Test-IsAdministrator { [CmdletBinding()] Param() $User = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() if ($User.IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { return $true } return $false } Enum Availability { # The component was detected Available # The component was not detected Unavailable # The component will be ignored # # This is distinct from "Unavailable" as it indicates the component is not # available on the underlying platform. Ignored # The component will always be installed AlwaysInstall # The component will never be installed NeverInstall # A failure occurred during component detection DetectionFailure # No detection logic was available NoLogic } Enum InstallState { # The component is installed Installed # The component is not installed NotInstalled # The component is partially installed # # After Get-DotFiles this typically means either: # - Additional files have been added since it was last installed # - A previous installation attempt was only partially successful # # After Install-DotFiles or Remove-DotFiles this typically means errors were # encountered during the installation or removal operation (or simulation). PartialInstall # The install state of the component can't be determined # # This can occur when attempting to install a component that has no files or # folders (because they're all ignored via the metadata or there are none). Unknown # The install state of the component has yet to be determined NotEvaluated } Class Component { # REQUIRED: This should match the corresponding dotfiles directory [String]$Name # REQUIRED: The availability state per the Availability enumeration [Availability]$Availability = [Availability]::DetectionFailure # OPTIONAL: Friendly name if one was provided or could be located [String]$FriendlyName # OPTIONAL: Hides newly created symlinks per the <HideSymlinks> element [Boolean]$HideSymlinks # INTERNAL: This will be set automatically based on the component name [System.IO.DirectoryInfo]$SourcePath # INTERNAL: Uninstall Registry key (populated by Find-DotFilesComponent) [String]$UninstallKey # INTERNAL: Determined by the <SpecialFolder> and <Destination> elements [String]$InstallPath # INTERNAL: Automatically set based on the <Path> elements in <IgnorePaths> [String[]]$IgnorePaths # INTERNAL: This will be set automatically during detection and installation [InstallState]$State = [InstallState]::NotEvaluated Component([String]$Name, [System.IO.DirectoryInfo]$DotFilesPath) { $this.Name = $Name $this.SourcePath = (Get-Item (Resolve-Path (Join-Path $DotFilesPath $Name))) } } |