├── .gitignore ├── CHANGELOG.md ├── CheckPrereqs.ps1 ├── ClipboardText.Tests.ps1 ├── ClipboardText.psd1 ├── ClipboardText.psm1 ├── LICENSE.md ├── README.md └── psakefile.ps1 /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # npm's debug files, created when something goes wrong. 3 | npm-debug.log 4 | 5 | # npm modules 6 | node_modules/ 7 | 8 | # urchin log files 9 | .urchin.log 10 | .urchin_stdout 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Versioning complies with [semantic versioning (semver)](http://semver.org/). 4 | 5 | 6 | 7 | * **v0.1.8** (2019-06-29): 8 | * [enhancement] For security and robustness, the standard shells and external clipboard programs are now invoked by full path (where known). 9 | 10 | * **v0.1.7** (2018-10-08): 11 | * [fix] for #5: The prerequisites-check script now runs without error even when `Set-StrictMode -Version Latest` is in effect in the caller's scope. 12 | 13 | * **v0.1.6** (2018-10-08): 14 | * [fix] for #4: A pointless warning is now no longer issued if `Set-ClipboardText` happens to be invoked while a UNC path is the current location (PSCore on Windows, WinPS v4-). 15 | 16 | * **v0.1.5** (2018-09-14): 17 | * [enhancement] Implements #3. `Get-ClipboardText` now uses a helper type that is compiled on demand for accessing the clipboard via the Windows API, to avoid use of WSH, which may be blocked for security reasons. 18 | 19 | * **v0.1.4** (2018-05-28): 20 | * [fix] With the exception of WinPSv5+ in STA mode (in wich case the built-in cmdlets are called), `clip.exe` (rather than `[System.Windows.Forms]`) is now used on Windows to avoid intermittent failures in MTA mode. 21 | Again, tip of the hat to @iricigor for encouraging me to use `clip.exe` consistently. 22 | 23 | * **v0.1.3** (2018-05-28): 24 | * [enhancement] Copying the empty string (i.e., effectively _clearing_ the clipboard) is now also supported in MTA threading mode (used in WinPSv2 by default, and optionally in WinPSv3+ if `powershell` is invoked with `-MTA`); thanks, @iricigor 25 | 26 | * **v0.1.2** (2018-05-25): 27 | * [fix] Fix for [#2](https://github.com/mklement0/ClipboardText/issues/2); the module now also works in Windows PowerShell when invoked with `-MTA`. 28 | 29 | * **v0.1.1** (2018-05-22): 30 | * Initial public release. 31 | -------------------------------------------------------------------------------- /CheckPrereqs.ps1: -------------------------------------------------------------------------------- 1 | # Checks whether all prerequisites for using this modules are met. 2 | # IMPORTANT: 3 | # Make sure this script runs properly even with Set-StrictMode -Version Latest 4 | # in effect; notably, make sure all variables accessed have been initialized. 5 | 6 | # If running on Linux and CLI `xclip` is not availble (via $PATH), 7 | # issue a warning with installation instructions. 8 | # Note: We must guard against access to potentially undefined variable $IsLinux 9 | # if the caller's scope happens to have Set-StrictMode -Version 1 or higher in effect. 10 | # We can't just use Set-StrictMode -Off, because this script is being *dot-sourced*, 11 | # which would modify the caller's environment. Note that it's safe to use the PSv3+ Ignore value 12 | # with -ErrorAction only in the -or clause, because it will only execute on Linux, where it is guaranteed to be supported. 13 | if ( 14 | -not ((Get-Variable -ErrorAction SilentlyContinue IsLinux) -and $IsLinux) <# on Windows and macOS we know that the required libraries / CLIs are present #> ` 15 | -or 16 | (Get-Command -Type Application -ErrorAction Ignore xclip) <# `xclip` is available #> 17 | ) { exit 0 } 18 | 19 | Write-Warning @' 20 | Your Linux environment is missing the `xclip` utility, which is required for 21 | the Set-ClipboardText and Get-ClipboardText cmdlets to function. 22 | 23 | PLEASE INSTALL `xclip` VIA YOUR PLATFORM'S PACKAGE MANAGER. 24 | E.g., on Debian-based distros such as Ubuntu, run: 25 | 26 | sudo apt install xclip 27 | 28 | '@ 29 | -------------------------------------------------------------------------------- /ClipboardText.Tests.ps1: -------------------------------------------------------------------------------- 1 | <# Note: 2 | * Make sure this file is saved as *UT8 with BOM*, so that 3 | literal non-ASCII characters are interpreted correctly. 4 | 5 | * When run in WinPSv3+, an attempt is made to run the tests in WinPSv2 6 | too, but note that requires prior installation of v2 support. 7 | Also, in PSv2, Pester must be loaded *manually*, via the *full 8 | path to its *.psd1 file* (seemingly, v2 doesn't find modules located in 9 | \\ subdirs.). 10 | For interactive use, the simplest approach is to invoke v2 as follows: 11 | powershell.exe -version 2 -Command "Import-Module '$((Get-Module Pester).Path)' 12 | #> 13 | 14 | # Abort on all unhandled errors. 15 | $ErrorActionPreference = 'Stop' 16 | 17 | # PSv2 compatibility: makes sure that $PSScriptRoot reflects this script's folder. 18 | if (-not $PSScriptRoot) { $PSScriptRoot = $MyInvocation.MyCommand.Path } 19 | 20 | # Turn on the latest strict mode, so as to make sure that the ScriptsToProcess 21 | # script that runs the prerequisites-check script dot-sourced also works 22 | # properly when the caller happens to run with Set-StrictMode -Version Latest 23 | # in effect. 24 | Set-StrictMode -Version Latest 25 | 26 | # Force-(re)import this module. 27 | # Target the *.psd1 file explicitly, so the tests can run from versioned subfolders too. Note that the 28 | # loaded module's ModuleInfo's .Path property will reflect the *.psm1 instead. 29 | $manifest = (Get-Item $PSScriptRoot/*.psd1) 30 | Remove-Module -ea Ignore -Force $manifest.BaseName # Note: To be safe, we unload any modules with the same name first (they could be in a different location and end up side by side in memory with this one.) 31 | # !! In PSv2, this statement causes Pester to run all tests TWICE (?!) 32 | Import-Module $manifest -Force -Global # -Global makes sure that when psake runs tester in a child scope, the module is still imported globally. 33 | 34 | # Use the platform-appropiate newline. 35 | $nl = [Environment]::NewLine 36 | 37 | # See if we're running on *Windows PowerShell* 38 | $isWinPs = $null, 'Desktop' -contains $PSVersionTable.PSEdition 39 | 40 | 41 | Describe StringInputTest { 42 | It "Copies and pastes a string correctly." { 43 | $string = "Here at $(Get-Date)" 44 | Set-ClipboardText $string 45 | Get-ClipboardText | Should -BeExactly $string 46 | } 47 | It "Correctly round-trips non-ASCII characters." { 48 | $string = 'Thomas Hübl''s talk about 中文' 49 | $string | Set-ClipboardText 50 | Get-ClipboardText | Should -BeExactly $string 51 | } 52 | It "Outputs an array of lines by default" { 53 | $lines = 'one', 'two' 54 | $string = $lines -join [Environment]::NewLine 55 | Set-ClipboardText $string 56 | Get-ClipboardText | Should -BeExactly $lines 57 | } 58 | It "Retrieves a multi-line string as-is with -Raw and doesn't append an extra newline." { 59 | "2 lines${nl}with 1 trailing newline${nl}" | Set-ClipboardText 60 | Get-ClipboardText -Raw | Should -Not -Match '(\r?\n){2}\z' 61 | } 62 | } 63 | 64 | Describe EmptyTests { 65 | BeforeEach { 66 | 'dummy' | Set-ClipboardText # make sure we start with a nonempty clipboard so we can verify that clearing is effective 67 | } 68 | It "Not providing input effectively clears the clipboard." { 69 | Set-ClipboardText # no input 70 | $null -eq (Get-ClipboardText -Raw) | Should -BeTrue 71 | } 72 | It "Passing the empty string effectively clears the clipboard." { 73 | Set-ClipboardText -InputObject '' # Note The PsWinV5+ Set-Clipboard reports a spurious error with '', which we mask behind the scenes. 74 | $null -eq (Get-ClipboardText -Raw) | Should -BeTrue 75 | } 76 | It "Passing `$null effectively clears the clipboard." { 77 | Set-ClipboardText -InputObject $null 78 | $null -eq (Get-ClipboardText -Raw) | Should -BeTrue 79 | } 80 | } 81 | 82 | Describe PassThruTest { 83 | It "Set-ClipboardText -PassThru also outputs the text." { 84 | $in = "line 1${nl}line 2" 85 | $out = $in | Set-ClipboardText -PassThru 86 | Get-ClipboardText -Raw | Should -BeExactly $in 87 | $out | Should -BeExactly $in 88 | } 89 | } 90 | 91 | Describe CommandInputTest { 92 | It "Copies and pastes a PowerShell command's output correctly" { 93 | Get-Item / | Set-ClipboardText 94 | # Note: Inside Set-ClipboardText we remove the trailing newline that 95 | # Out-String invariably adds, so we must do the same here. 96 | $shouldBe = (Get-Item / | Out-String) -replace '\r?\n\z' 97 | $pasted = Get-ClipboardText -Raw 98 | $pasted | Should -BeExactly $shouldBe 99 | } 100 | It "Copies and pastes an external program's output correctly" { 101 | # Note: whoami without argument works on all supported platforms. 102 | whoami | Set-ClipboardText 103 | $shouldBe = whoami 104 | $is = Get-ClipboardText 105 | $is | Should -Be $shouldBe 106 | } 107 | } 108 | 109 | Describe OutputWidthTest { 110 | BeforeAll { 111 | # A custom object that is implicitly formatted with Format-Table with 112 | # 2 columns. 113 | $obj = [pscustomobject] @{ one = '1' * 40; two = '2' * 216 } 114 | } 115 | It "Truncates lines that are too wide for the specified width" { 116 | $obj | Set-ClipboardText -Width 80 117 | # Note: [3] - the *4th* line - is the line with the two column values in all editions. 118 | # [-2] to use the penultimate line is NOT reliable, as the editions differ in 119 | # the number of trailing newlines. 120 | (Get-ClipboardText)[3] | Should -Match '(\.\.\.|…)$' # Note: At some point, PS Core started using the '…' (horizontal ellipsis) Unicode char. instead of three periods. 121 | } 122 | It "Allows incrementing the width to accommodate wider lines" { 123 | $obj | Set-ClipboardText -Width 257 # 40 + 1 (space between columns) + 216 124 | (Get-ClipboardText)[3].TrimEnd() | Should -BeLikeExactly '*2' 125 | } 126 | } 127 | 128 | # Note: These tests apply to PS *Core* only, because Windows PowerShell doesn't require external utilities for clipboard support. 129 | Describe MissingExternalUtilityTest { 130 | 131 | # We skip these tests in *Windows PowerShell*, because Windows Powershell 132 | # does't require external utilities for access to the clipboard. 133 | # Note: We don't exit right away, because do want to invoke the `It` block 134 | # with `-Skip` set to $True, so that the results indicated that the test 135 | # was deliberately skipped. 136 | if (-not $isWinPs) { 137 | 138 | # Determine the name of the module being tested. 139 | # For a Mock to be effective in the target module's context, it must be 140 | # defined with -ModuleName . 141 | $thisModuleName = (Split-Path -Leaf $PSScriptRoot) 142 | 143 | # Define the platform-appropiate mocks for calling the external clipboard 144 | # utilities. 145 | # Note: Since mocking by full executable path isn't supported, we use 146 | # helper function invoke-External. 147 | 148 | # ??? TODO: These tests no longer work in Pester 5.x 149 | # macOS, Linux: 150 | Mock invoke-External -ParameterFilter { $LiteralPath -eq '/bin/sh' } { 151 | /bin/sh -c 'nosuchexe' 152 | } -ModuleName $thisModuleName 153 | 154 | # Windows: 155 | Mock invoke-External -ParameterFilter { $LiteralPath -eq "$env:SystemRoot\System32\cmd.exe" } { 156 | & "$env:SystemRoot\System32\cmd.exe" /c 'nosuchexe' 157 | } -ModuleName $thisModuleName 158 | 159 | } 160 | 161 | It "PS Core: Generates a statement-terminating error when the required external utility is not present" -Skip:$isWinPs { 162 | { 'dummy' | Set-ClipboardText 2>$null } | Should -Throw 163 | } 164 | 165 | } 166 | 167 | Describe MTAtests { 168 | # Windows PowerShell: 169 | # A WinForms text-box workaround is needed when PowerShell is running in COM MTA 170 | # (multi-threaded apartment) mode. 171 | # By default, v2 runs in MTA mode and v3+ in STA mode. 172 | # However, you can *opt into* MTA mode in v3+, and the workaround is then needed too. 173 | # (In PSCore on Windows, MTA is the default again, but it has no access to WinForms 174 | # anyway and uses external utility clip.exe instead.) 175 | It "Windows PowerShell: Works in MTA mode" -Skip:(-not $isWinPs -or $PSVersionTable.PSVersion.Major -eq 2) { 176 | # Recursively invokes the 'StringInputTest' tests. 177 | # !! This produces NO OUTPUT; to troubleshoot, run the command interactively from the project folder. 178 | # !! As of Windows PowerShell v5.1.18362.145 on Microsoft Windows 10 Pro (64-bit; Version 1903, OS Build: 18362.175), 179 | # !! `Get-Command -Name Add-Member, Get-ChildItem` must be executed BEFORE invoking Pester; without it, 180 | # !! Pester inexplicably fails to locate these commands during module import and cannot be loaded. 181 | powershell.exe -noprofile -MTA -Command "if ([threading.thread]::CurrentThread.ApartmentState.ToString() -ne 'MTA') { Throw "Not in MTA mode." }; Get-Command -Name Add-Member, Get-ChildItem; Invoke-Pester -Name StringInputTest -EnableExit" 182 | $LASTEXITCODE | Should -Be 0 183 | } 184 | } 185 | 186 | Describe v2Tests { 187 | # Invoke these tests in *WinPS v2*, which amounts to a RECURSION. 188 | # Therefore, EXECUTION TAKES A WHILE. 189 | It "Windows PowerShell: Passes all tests in v2 as well." -Skip:(-not $isWinPs -or $PSVersionTable.PSVersion.Major -eq 2) { 190 | # !! An Install-Module-installed Pester is located in a version-named subfolder, which v2 cannot 191 | # !! detect, so we import Pester by explicit path. 192 | # !! Also `-version 2` must be the *first* argument passed to `powershell.exe`. 193 | # 194 | # !! NO OUTPUT IS PRODUCED - to troubleshoot, run the command interactively from the project folder. 195 | # !! Notably, *prior installation of v2 support is needed*, and PowerShell seems to quietly ignore `-version 2` 196 | # !! in its absence, so we have to test from *within* the session. 197 | powershell.exe -version 2 -noprofile -Command "Set-StrictMode -Version Latest; Import-Module '$((Get-Module Pester).Path)'; if (`$PSVersionTable.PSVersion.Major -ne 2) { Throw 'v2 SUPPORT IS NOT INSTALLED.' }; Invoke-Pester" 198 | $LASTEXITCODE | Should -Be 0 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /ClipboardText.psd1: -------------------------------------------------------------------------------- 1 | # Module manifest - save as BOM-less UTF-8 and USE ONLY ASCII CHARACTER IN THIS FILE 2 | @{ 3 | 4 | # Script module or binary module file associated with this manifest. 5 | # 'ModuleToProcess' has been renamed to 'RootModule', but older PS versions still require the old name. 6 | ModuleToProcess = 'ClipboardText.psm1' 7 | 8 | ScriptsToProcess = 'CheckPrereqs.ps1' 9 | 10 | # Version number of this module. 11 | ModuleVersion = '0.1.8' 12 | 13 | # Supported PSEditions 14 | # !! This keys is not supported in older PS versions. 15 | # CompatiblePSEditions = 'Core', 'Desktop' 16 | 17 | # ID used to uniquely identify this module 18 | GUID = '74a03733-2ae5-4f26-ac06-f2939e1a79f9' 19 | 20 | # Author of this module 21 | Author = 'Michael Klement ' 22 | 23 | # Copyright statement for this module 24 | Copyright = '(c) 2018 Michael Klement , released under the [MIT license](http://opensource.org/licenses/MIT)' 25 | 26 | # Description of the functionality provided by this module 27 | Description = 'Support for text-based clipboard operations for PowerShell Core (cross-platform) and older versions of Windows PowerShell' 28 | 29 | # Minimum version of the Windows PowerShell engine required by this module 30 | PowerShellVersion = '2.0' 31 | 32 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 33 | FunctionsToExport = 'Set-ClipboardText', 'Get-ClipboardText' 34 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 35 | AliasesToExport = 'scbt', 'gcbt' 36 | 37 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 38 | PrivateData = @{ 39 | 40 | PSData = @{ 41 | 42 | # Tags applied to this module. These help with module discovery in online galleries. 43 | Tags = 'clipboard','text','cross-platform' 44 | 45 | # A URL to the license for this module. 46 | LicenseUri = 'https://github.com/mklement0/ClipboardText/blob/master/LICENSE.md' 47 | 48 | # A URL to the main website for this project. 49 | ProjectUri = 'https://github.com/mklement0/ClipboardText' 50 | 51 | # ReleaseNotes of this module - point this to the changelog section of the read-me 52 | ReleaseNotes = 'https://github.com/mklement0/ClipboardText/blob/master/CHANGELOG.md' 53 | 54 | } # End of PSData hashtable 55 | 56 | } # End of PrivateData hashtable 57 | 58 | } 59 | -------------------------------------------------------------------------------- /ClipboardText.psm1: -------------------------------------------------------------------------------- 1 | <# 2 | 3 | IMPORTANT: THIS MODULE MUST REMAIN PSv2-COMPATIBLE. 4 | 5 | #> 6 | 7 | # Module-wide defaults. 8 | 9 | # !! PSv2: We do not even activate the check for accessing nonexistent variables, because 10 | # !! of a pitfall where parameter variables belonging to a parameter set 11 | # !! other than the one selected by a given invocation are considered undefined. 12 | if ($PSVersionTable.PSVersion.Major -gt 2) { 13 | Set-StrictMode -Version 1 14 | } 15 | 16 | #region == ALIASES 17 | Set-Alias scbt Set-ClipboardText 18 | Set-Alias gcbt Get-ClipboardText 19 | #endregion 20 | 21 | #region == Exported functions 22 | 23 | function Get-ClipboardText { 24 | <# 25 | .SYNOPSIS 26 | Gets text from the clipboard. 27 | 28 | .DESCRIPTION 29 | Retrieves text from the system clipboard as an arry of lines (by default) 30 | or as-is (with -Raw). 31 | 32 | If the clipboard is empty or contains no text, $null is returned. 33 | 34 | LINUX CAVEAT: The xclip utility must be installed; on Debian-based platforms 35 | such as Ubuntu, install it with: sudo apt install xclip 36 | 37 | .PARAMETER Raw 38 | Output the retrieved text as-is, even if it spans multiple lines. 39 | By default, if the retrieved text is a multi-line string, each line is 40 | output individually. 41 | 42 | .NOTES 43 | This function is a "polyfill" to make up for the lack of built-in clipboard 44 | support in Windows Powershell v5.0- and in PowerShell Core as of v6.1, 45 | albeit only with respect to text. 46 | 47 | In Windows PowerShell v5+, you can use the built-in Get-Clipboard cmdlet 48 | instead (which this function invokes, if available). 49 | 50 | In earlier versions, a helper type is compiled on demand that uses the 51 | Windows API. Note that this means the first invocation of this function 52 | in a given session will be noticeably slower, due to the on-demand compilation. 53 | 54 | .EXAMPLE 55 | Get-ClipboardText | ForEach-Object { $i=0 } { '#{0}: {1}' -f (++$i), $_ } 56 | 57 | Retrieves text from the clipboard and sends its lines individually through 58 | the pipeline, using a ForEach-Object command to prefix each line with its 59 | line number. 60 | 61 | .EXAMPLE 62 | Get-ClipboardText -Raw > out.txt 63 | 64 | Retrieves text from the clipboard as-is and saves it to file out.txt 65 | (with a newline appended). 66 | #> 67 | 68 | [CmdletBinding()] 69 | [OutputType([string])] 70 | param( 71 | [switch] $Raw 72 | ) 73 | 74 | $rawText = $lines = $null 75 | # *Windows PowerShell* v5+ in *STA* COM threading mode (which is the default, but it can be started with -MTA) 76 | if ((test-WindowsPowerShell) -and $PSVersionTable.PSVersion.Major -ge 5 -and 'STA' -eq [threading.thread]::CurrentThread.ApartmentState.ToString()) { 77 | 78 | Write-Verbose "Windows (PSv5+ in STA mode): deferring to Get-Clipboard" 79 | if ($Raw) { 80 | $rawText = Get-Clipboard -Format Text -Raw 81 | } else { 82 | $lines = Get-Clipboard -Format Text 83 | } 84 | 85 | } else { # Windows PowerShell v4- and/or in MTA threading mode, PowerShell *Core* on any supported platform. 86 | 87 | # No native PS support for writing to the clipboard or native support not available due to MTA mode -> external utilities 88 | # must be used. 89 | # (Note: Attempts to use [System.Windows.Forms] proved to be brittle in MTA mode, causing intermittent failures.) 90 | # Since PS automatically splits external-program output into individual 91 | # lines and trailing empty lines can get lost in the process, we 92 | # must, unfortunately, send the text to a temporary *file* and read 93 | # that. 94 | 95 | $isWin = $env:OS -eq 'Windows_NT' # Note: $IsWindows is only available in PS *Core*. 96 | 97 | if ($isWin) { 98 | 99 | Write-Verbose "Windows: using WinAPI via helper type" 100 | 101 | # Note: Originally we used a WSH-based solution a la http://stackoverflow.com/a/15747067/45375, 102 | # but WSH may be blocked on some systems for security reasons. 103 | add-WinApiHelperType 104 | 105 | $rawText = [net.same2u.util.Clipboard]::GetText() 106 | 107 | } else { 108 | 109 | $tempFile = [io.path]::GetTempFileName() 110 | 111 | try { 112 | 113 | # Note: For security reasons, we want to make sure it is the actual standard 114 | # shell we're invoking on each platform, so we use its full path. 115 | # Similarly, for clipboard utilities that are standard on a given platform, 116 | # we use their full paths. 117 | # Mocking executables invoked by their full paths isn't directly supported 118 | # in Pester, so we use helper function invoke-External, which *can* be mocked. 119 | 120 | if ($IsMacOS) { 121 | 122 | Write-Verbose "macOS: using pbpaste" 123 | 124 | invoke-External /bin/sh -c "/usr/bin/pbpaste > '$tempFile'" 125 | 126 | } else { # $IsLinux 127 | 128 | Write-Verbose "Linux: using xclip" 129 | 130 | # Note: Requires xclip, which is not installed by default on most Linux distros 131 | # and works with freedesktop.org-compliant, X11 desktops. 132 | # Note: Since xclip is not an in-box utility, we make no assumptions 133 | # about its specific location and rely on it to be in $env:PATH. 134 | invoke-External /bin/sh -c "xclip -selection clipboard -out > '$tempFile'" 135 | # Check for the specific exit code that indicates that `xclip` wasn't found and provide an installation hint. 136 | if ($LASTEXITCODE -eq 127) { new-StatementTerminatingError "xclip is not installed; please install it via your platform's package manager; e.g., on Debian-based distros such as Ubuntu: sudo apt install xclip" } 137 | 138 | } 139 | 140 | if ($LASTEXITCODE) { new-StatementTerminatingError "Invoking the native clipboard utility failed unexpectedly." } 141 | 142 | # Read the contents of the temp. file into a string variable. 143 | # Temp. file is UTF8, which is the default encoding 144 | $rawText = [IO.File]::ReadAllText($tempFile) 145 | 146 | } finally { 147 | Remove-Item $tempFile 148 | } 149 | } # -not $isWin 150 | } 151 | 152 | # Output the retrieved text 153 | if ($Raw) { # as-is (potentially multi-line) 154 | $result = $rawText 155 | } else { # as an array of lines (as the PsWinV5+ Get-Clipboard cmdlet does) 156 | if ($null -eq $lines) { 157 | # Note: This returns [string[]] rather than [object[]], but that should be fine. 158 | $lines = $rawText -split '\r?\n' 159 | } 160 | $result = $lines 161 | } 162 | 163 | # If the effective result is the *empty string* [wrapped in a single-element array], we output 164 | # $null, because that's what the PsWinV5+ Get-Clipboard cmdlet does. 165 | if (-not $result) { 166 | # !! To be consistent with Get-Clipboard, we output $null even in the absence of -Raw, 167 | # !! even though you could argue that *nothing* should be output (i.e., implicitly, the "arry-valued null", 168 | # !! [System.Management.Automation.Internal.AutomationNull]::Value) 169 | # !! so that trying to *enumerate* the result sends nothing through the pipeline. 170 | # !! (A similar, but opposite inconsistency is that Get-Content with a zero-byte file outputs the "array-valued null" 171 | # !! both with and without -Raw). 172 | $null 173 | } else { 174 | $result 175 | } 176 | 177 | } 178 | 179 | function Set-ClipboardText { 180 | <# 181 | .SYNOPSIS 182 | Copies text to the clipboard. 183 | 184 | .DESCRIPTION 185 | Copies a text representation of the input to the system clipboard. 186 | 187 | Input can be provided via the pipeline or via the -InputObject parameter. 188 | 189 | If you provide no input, the empty string, or $null, the clipboard is 190 | effectively cleared. 191 | 192 | Non-text input is formatted the same way as it would print to the console, 193 | which means that the console/terminal window's [buffer] width determines 194 | the output line width, which may result in truncated data (indicated with 195 | "..."). 196 | To avoid that, you can increase the max. line width with -Width, but see 197 | the caveats in the parameter description. 198 | 199 | LINUX CAVEAT: The xclip utility must be installed; on Debian-based platforms 200 | such as Ubuntu, install it with: sudo apt install xclip 201 | 202 | .PARAMETER Width 203 | For non-text input, determines the maximum output-line length. 204 | The default is Out-String's default, which is the current console/terminal 205 | window's [buffer] width. 206 | 207 | Be careful with high values and avoid [int]::MaxValue, however, because in 208 | the case of (implicit) Format-Table output each output line is padded to 209 | that very width, which can require a lot of memory. 210 | 211 | .PARAMETER PassThru 212 | In addition to copying the resulting string representation of the input to 213 | the clipboard, also outputs it, as single string. 214 | 215 | .NOTES 216 | This function is a "polyfill" to make up for the lack of built-in clipboard 217 | support in Windows Powershell v5.0- and in PowerShell Core as of v6.1, 218 | albeit only with respect to text. 219 | In Windows PowerShell v5.1+, you can use the built-in Set-Clipboard cmdlet 220 | instead (which this function invokes, if available). 221 | 222 | .EXAMPLE 223 | Set-ClipboardText "Text to copy" 224 | 225 | Copies the specified text to the clipboard. 226 | 227 | .EXAMPLE 228 | Get-ChildItem -File -Name | Set-ClipboardText 229 | 230 | Copies the names of all files the current directory to the clipboard. 231 | 232 | .EXAMPLE 233 | Get-ChildItem | Set-ClipboardText -Width 500 234 | 235 | Copies the text representations of the output from Get-ChildItem to the 236 | clipboard, ensuring that output lines are 500 characters wide. 237 | #> 238 | 239 | [CmdletBinding(DefaultParameterSetName='Default')] # !! PSv2 doesn't support PositionalBinding=$False 240 | [OutputType([string], ParameterSetName='PassThru')] 241 | param( 242 | [Parameter(Position=0, ValueFromPipeline = $True)] # Note: The built-in PsWinV5.0+ Set-Clipboard cmdlet does NOT have mandatory input, in which case the clipbard is effectively *cleared*. 243 | [AllowNull()] # Note: The built-in PsWinV5.0+ Set-Clipboard cmdlet allows $null too. 244 | $InputObject 245 | , 246 | [int] $Width # max. output-line width for non-string input 247 | , 248 | [Parameter(ParameterSetName='PassThru')] 249 | [switch] $PassThru 250 | ) 251 | 252 | begin { 253 | # Initialize an array to collect all input objects in. 254 | # !! Incredibly, in PSv2 using either System.Collections.Generic.List[object] or 255 | # !! System.Collections.ArrayList ultimately results in different ... | Out-String 256 | # !! output, with the group header ('Directory:') for input `GetItem / | Out-String` 257 | # !! inexplicably missing - even .ToArray() conversion or an [object[]] cast 258 | # !! before piping to Out-String doesn't help. 259 | # !! Given that we don't expect large collections to be sent to the clipboard, 260 | # !! we make do with inefficiently "growing" an *array* ([object[]]), i.e. 261 | # !! cloning the old array for each input object. 262 | $inputObjs = @() 263 | } 264 | 265 | process { 266 | # Collect the input objects. 267 | $inputObjs += $InputObject 268 | } 269 | 270 | end { 271 | 272 | # * The input as a whole is converted to a a single string with 273 | # Out-String, which formats objects the same way you would see on the 274 | # console. 275 | # * Since Out-String invariably appends a trailing newline, we must remove it. 276 | # (The PS Core v6 -NoNewline switch is NOT an option, as it also doesn't 277 | # place newlines *between* objects.) 278 | $widthParamIfAny = if ($PSBoundParameters.ContainsKey('Width')) { @{ Width = $Width } } else { @{} } 279 | $allText = ($inputObjs | Out-String @widthParamIfAny) -replace '\r?\n\z' 280 | 281 | # *Windows PowerShell* v5+ in *STA* COM threading mode (which is the default, but it can be started with -MTA) 282 | if ((test-WindowsPowerShell) -and $PSVersionTable.PSVersion.Major -ge 5 -and 'STA' -eq [threading.thread]::CurrentThread.ApartmentState.ToString()) { 283 | 284 | # !! As of PsWinV5.1, `Set-Clipboard ''` reports a spurious error (but still manages to effectively) clear the clipboard. 285 | # !! By contrast, using `Set-Clipboard $null` succeeds. 286 | Set-Clipboard -Value ($allText, $null)[$allText.Length -eq 0] 287 | 288 | } else { # Windows PowerShell v4- and/or in MTA threading mode, PowerShell *Core* on any supported platform. 289 | 290 | # No native PS support for writing to the clipboard or native support not available due to MTA mode -> 291 | # external utilities must be used. 292 | # (Note: Attempts to use [System.Windows.Forms] proved to be brittle in MTA mode, causing intermittent failures.) 293 | 294 | $isWin = $env:OS -eq 'Windows_NT' # Note: $IsWindows is only available in PS *Core*. 295 | 296 | # To prevent adding a trailing \n, which PS inevitably adds when sending 297 | # a string through the pipeline to an external command, use a temp. file, 298 | # whose content can be provided via native input redirection (<) 299 | $tmpFile = [io.path]::GetTempFileName() 300 | 301 | if ($isWin) { 302 | # The clip.exe utility requires *BOM-less* UTF16-LE for full Unicode support. 303 | [IO.File]::WriteAllText($tmpFile, $allText, (New-Object System.Text.UnicodeEncoding $False, $False)) 304 | } else { # $IsUnix -> use BOM-less UTF8 305 | # PowerShell's UTF8 encoding invariably creates a file WITH BOM 306 | # so we use the .NET Framework, whose default is BOM-*less* UTF8. 307 | [IO.File]::WriteAllText($tmpFile, $allText) 308 | } 309 | 310 | # Feed the contents of the temporary file via stdin to the 311 | # platform-appropriate clipboard utility. 312 | try { 313 | 314 | # Note: For security reasons, we want to make sure it is the actual standard 315 | # shell we're invoking on each platform, so we use its full path. 316 | # Similarly, for clipboard utilities that are standard on a given platform, 317 | # we use their full paths. 318 | # Mocking executables invoked by their full paths isn't directly supported 319 | # in Pester, so we use helper function invoke-External, which *can* be mocked. 320 | 321 | if ($isWin) { 322 | 323 | Write-Verbose "Windows: using clip.exe" 324 | 325 | # !! Temporary switch to the system drive (a drive guaranteed to be local) so as to 326 | # !! prevent cmd.exe from issuing a warning if a UNC path happens to be the current location 327 | # !! - see https://github.com/mklement0/ClipboardText/issues/4 328 | Push-Location -LiteralPath $env:SystemRoot 329 | invoke-External "$env:SystemRoot\System32\cmd.exe" /c "$env:SystemRoot\System32\clip.exe" '<' $tmpFile 330 | Pop-Location 331 | 332 | } elseif ($IsMacOS) { 333 | 334 | Write-Verbose "macOS: using pbcopy" 335 | 336 | invoke-External /bin/sh -c "/usr/bin/pbcopy < '$tmpFile'" 337 | 338 | } else { # $IsLinux 339 | 340 | Write-Verbose "Linux: using xclip" 341 | # Note: Since xclip is not an in-box utility, we make no assumptions 342 | # about its specific location and rely on it to be in $env:PATH. 343 | # !! >&- (i.e., closing stdout) is necessary, because xclip hangs if you try to redirect its - nonexistent output with `-in`, which also happens impliclity via `$null = ...` in the context of Pester tests. 344 | invoke-External /bin/sh -c "xclip -selection clipboard -in < '$tmpFile' >&-" 345 | 346 | # Check for the specific exit code that indicates that `xclip` wasn't found and provide an installation hint. 347 | if ($LASTEXITCODE -eq 127) { new-StatementTerminatingError "xclip is not installed; please install it via your platform's package manager; e.g., on Debian-based distros such as Ubuntu: sudo apt install xclip" } 348 | 349 | } 350 | 351 | if ($LASTEXITCODE) { new-StatementTerminatingError "Invoking the platform-specific clipboard utility failed unexpectedly." } 352 | 353 | } finally { 354 | Pop-Location # Restore the previously current location. 355 | Remove-Item $tmpFile 356 | } 357 | 358 | } 359 | 360 | if ($PassThru) { 361 | $allText 362 | } 363 | 364 | } 365 | 366 | } 367 | 368 | #endregion 369 | 370 | #region == Private helper functions 371 | 372 | # Throw a statement-terminating error (instantly exits the calling function and its enclosing statement). 373 | function new-StatementTerminatingError([string] $Message, [System.Management.Automation.ErrorCategory] $Category = 'InvalidOperation') { 374 | $PSCmdlet.ThrowTerminatingError((New-Object System.Management.Automation.ErrorRecord ` 375 | $Message, 376 | $null, # a custom error ID (string) 377 | $Category, # the PS error category - do NOT use NotSpecified - see below. 378 | $null # the target object (what object the error relates to) 379 | )) 380 | } 381 | 382 | # Determine if we're runnning in Windows PowerShell. 383 | function test-WindowsPowerShell { 384 | # !! $IsCoreCLR is not available in Windows PowerShell and, if 385 | # !! Set-StrictMode is set, trying to access it would fail. 386 | $null, 'Desktop' -contains $PSVersionTable.PSEdition 387 | } 388 | 389 | # Helper function for invoking an external utility (executable). 390 | # The raison d'être for this function is so that calls to utilities called 391 | # with their *full paths* can be mocked in Pester. 392 | function invoke-External { 393 | param( 394 | [Parameter(Mandatory=$true)] 395 | [string] $LiteralPath, 396 | [Parameter(ValueFromRemainingArguments=$true)] 397 | $PassThruArgs 398 | ) 399 | & $LiteralPath $PassThruArgs 400 | } 401 | 402 | 403 | # Adds helper type [net.same2u.util.Clipboard] for clipboard access via the 404 | # Windows API. 405 | # Note: It is fine to blindly call this function repeatedly - after the initial 406 | # performance hit due to compilation, subsequent invocations are very fast. 407 | function add-WinApiHelperType { 408 | Add-Type -Name Clipboard -Namespace net.same2u.util -MemberDefinition @' 409 | [DllImport("user32.dll", SetLastError=true)] 410 | static extern bool OpenClipboard(IntPtr hWndNewOwner); 411 | [DllImport("user32.dll", SetLastError = true)] 412 | static extern IntPtr GetClipboardData(uint uFormat); 413 | [DllImport("user32.dll", SetLastError=true)] 414 | static extern bool CloseClipboard(); 415 | 416 | public static string GetText() { 417 | string txt = null; 418 | if (!OpenClipboard(IntPtr.Zero)) { throw new Exception("Failed to open clipboard."); } 419 | IntPtr handle = GetClipboardData(13); // CF_UnicodeText 420 | if (handle != IntPtr.Zero) { // if no handle is returned, assume that no text was on the clipboard. 421 | txt = Marshal.PtrToStringAuto(handle); 422 | } 423 | if (!CloseClipboard()) { throw new Exception("Failed to close clipboard."); } 424 | return txt; 425 | } 426 | '@ 427 | } 428 | 429 | #endregion 430 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Michael Klement (http://same2u.net), released under the [MIT license](https://spdx.org/licenses/MIT#licenseText). 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PowerShell Gallery](https://img.shields.io/powershellgallery/dt/ClipboardText.svg)](https://powershellgallery.com/packages/ClipboardText) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mklement0/ClipboardText/blob/master/LICENSE.md) 2 | 3 | # Clipboard text support for PowerShell Core (cross-platform) and Windows PowerShell v2-v4 4 | 5 | `ClipboardText` is a **cross-edition, cross-platform PowerShell module** that provides support 6 | for **copying text to and retrieving text from the system clipboard**, via the `Set-ClipboardText` and `Get-ClipboardText` cmdlets. 7 | 8 | It is useful in the following **scenarios**: 9 | 10 | * **Use with PowerShell _Core_ on (hopefully) all supported platforms.** 11 | 12 | * As of v6.1, PowerShell Core doesn't ship with clipboard cmdlets. 13 | * This module fills this gap, albeit only with respect to _text_. 14 | * The implementation relies on external utilities (command-line programs) on all supported platforms: 15 | * Windows: `clip.exe` (built in) 16 | * macOS: `pbcopy` and `pbpaste` (built in) 17 | * Linux: [`xclip`](https://github.com/astrand/xclip) (_requires installation_ via the system's package manager; e.g. `sudo apt-get install xclip`; available on X11-based [freedesktop.org](https://www.freedesktop.org/wiki/)-compliant desktops, such as on Ubuntu) 18 | 19 | * **Use with _older versions_ of _Windows PowerShell_.** 20 | 21 | * Only since v5.0 does Windows PowerShell ship with `Set-Clipboard` and `Get-Clipboard` cmdlets. 22 | * This module fills the gap for v2-v4, albeit only with respect to _text_. 23 | * For implementing backward-compatible functionality, you may also use this module in v5+, in which case this module's cmdlets call the built-in ones behind the scenes. 24 | * On older versions, the implementation uses [Windows Forms](https://en.wikipedia.org/wiki/Windows_Forms) .NET types behind the scenes (namespace `System.Windows.Forms`) 25 | 26 | * **Use in _universal scripts_.** 27 | * Universal scripts are scripts that run on both Windows PowerShell and Powershell Core, on all supported platforms, including older versions of Windows PowerShell; in this case, down to version 2. 28 | 29 | # Installation 30 | 31 | ## Installation from the PowerShell Gallery 32 | 33 | **Prerequisite**: The `PowerShellGet` module must be installed (verify with `Get-Command Install-Module`). 34 | `PowerShellGet` comes with PowerShell version 5 or higher; it is possible to manually install it on versions 3 and 4 - see [the docs](https://docs.microsoft.com/en-us/powershell/scripting/gallery/installing-psget). 35 | 36 | * Current-user-only installation: 37 | 38 | ```powershell 39 | # Installation for the current user only. 40 | PS> Install-Module ClipboardText -Scope CurrentUser 41 | ``` 42 | 43 | * All-users installation (requires elevation / `sudo`): 44 | 45 | ```powershell 46 | # Installation for ALL users. 47 | # IMPORTANT: Requires an ELEVATED session: 48 | # On Windows: 49 | # Right-click on the Windows PowerShell icon and select "Run as Administrator". 50 | # On Linux and macOS: 51 | # Run `sudo pwsh` from an existing terminal. 52 | ELEV-PS> Install-Module ClipboardText -Scope AllUsers 53 | ``` 54 | 55 | See also: [this repo's page in the PowerShell Gallery](https://www.powershellgallery.com/packages/ClipboardText). 56 | 57 | ## Manual Installation 58 | 59 | If you're still using PowerShell v2, manual installation is your only option. 60 | 61 | Clone this repository (as a subfolder) into one of the directories listed in the `$env:PSModulePath` variable; e.g., to install the module in the context of the current user, choose the following parent folders: 62 | 63 | * **Windows**: 64 | * Windows PowerShell: `$HOME\Documents\WindowsPowerShell\Modules` 65 | * PowerShell Core: `$HOME\Documents\PowerShell\Modules` 66 | * **macOs, Linux** (PowerShell Core): 67 | * `$HOME/.local/share/powershell/Modules` 68 | 69 | As long as you've cloned into one of the directories listed in the `$env:PSModulePath` variable - copying to some of which requires elevation / `sudo` - and as long your `$PSModuleAutoLoadingPreference` is not set (the default) or set to `All`, calling `Set-ClipboardText` or `Get-ClipboardText` should import the module on demand - except in _PowerShell v2_. 70 | 71 | To explicitly import the module, run `Import-Module `. 72 | 73 | **Example**: Install as a current-user-only module: 74 | 75 | Note: Assumes that [`git`](https://git-scm.com/) is installed. 76 | 77 | ```powershell 78 | # Switch to the parent directory of the current user's modules. 79 | Set-Location $(if ($env:OS -eq 'Windows_NT') { "$HOME\Documents\{0}\Modules" -f ('WindowsPowerShell', 'PowerShell')[[bool]$IsCoreClr] } else { "$HOME/.local/share/powershell/Modules" }) 80 | # Clone this repo into subdir. 'ClipboardText'; --depth 1 gets only the latest revision. 81 | git clone --depth 1 --quiet https://github.com/mklement0/ClipboardText 82 | ``` 83 | 84 | On _Windows PowerShell v2_, you must now explicitly load the module: 85 | 86 | ```powershell 87 | Import-Module -Verbose .\ClipboardText 88 | ``` 89 | 90 | Run `Set-ClipboardText -?` to verify that installation succeeded and that the module is loaded on demand (PSv3+): 91 | you should see brief CLI help text. 92 | 93 | # Usage 94 | 95 | In short: 96 | 97 | * `Set-ClipboardText` copies strings as-is; output from commands is copied using the same representation you see in the console, essentially obtained via `Out-String`; e.g.: 98 | 99 | ```powershell 100 | # Copy the full path of the current filesystem location to the clipbard: 101 | $PWD.Path | Set-ClipboardText 102 | 103 | # Copy the names of all files in the current directory to the clipboard: 104 | Get-ChildItem -File -Name | Set-ClipboardText 105 | ``` 106 | 107 | * `Get-ClipboardText` retrieves text from the clipboard as an _array of lines_ by default; use `-Raw` to request the text as-is, as a potentially multi-line string. 108 | 109 | ```powershell 110 | # Retrieve text from the clipboard as a single string and save it to a file: 111 | Get-ClipboardText -Raw > out.txt 112 | 113 | # Retrieve text from the clipboard as an array of lines and prefix each with 114 | # a line number: 115 | Get-ClipboardText | ForEach-Object { $i=0 } { '#{0}: {1}' -f (++$i), $_ } 116 | ``` 117 | 118 | For more, consult the **built-in help** after installation: 119 | 120 | ```powershell 121 | # Concise command-line help with terse description and syntax diagram. 122 | Get-ClipboardText -? 123 | Set-ClipboardText -? 124 | 125 | # Full help, including parameter descriptions and details and examples. 126 | Get-Help -Full Get-ClipboardText 127 | Get-Help -Full Set-ClipboardText 128 | 129 | # Examples only 130 | Get-Help -Examples Get-ClipboardText 131 | Get-Help -Examples Set-ClipboardText 132 | ``` 133 | 134 | # License 135 | 136 | See [LICENSE.md](./LICENSE.md). 137 | 138 | # Changelog 139 | 140 | See [CHANGELOG.md](./CHANGELOG.md). 141 | -------------------------------------------------------------------------------- /psakefile.ps1: -------------------------------------------------------------------------------- 1 | # Note: 2 | # * Invoke this file with Invoke-PSake (or an alias such as ips) from the 3 | # psake module. 4 | # * By default, psake behaves as if $ErrorActionPreference = 'Stop' had been set. 5 | # I.e., *any* PS errors - even nonterminating ones - abort execution by default. 6 | 7 | properties { 8 | 9 | $thisModuleName = (Get-Item $PSScriptRoot/*.psd1).BaseName 10 | # A single hashtable for all script-level properties. 11 | $props = @{ 12 | 13 | # == Properties derived from optional parameters (passed with -parameter @{ ... }) 14 | # ?? Is there a way we can query all parameters passed so we can error out 15 | # ?? on detecting unknown ones? 16 | # Supported parameters (pass with -parameter @{ = [; ...] }): 17 | # 18 | # SkipTest[s] / NoTest[s] ... [Boolean]; if $True, skips execution of tests 19 | # Force / Yes ... [Boolean]; skips confirmation prompts 20 | # 21 | SkipTests = $SkipTests -or $SkipTest -or $NoTests -or $NoTest 22 | SkipPrompts = $Force -or $Yes 23 | 24 | # == Internally used / derived properties. 25 | ModuleName = $thisModuleName 26 | Files = @{ 27 | GlobalConfig = "$HOME/.new-moduleproject.config.psd1" 28 | Manifest = "$thisModuleName.psd1" 29 | ChangeLog = "$PSScriptRoot/CHANGELOG.md" 30 | ReadMe = "$PSScriptRoot/README.md" 31 | License = "$PSScriptRoot/LICENSE.md" 32 | } 33 | 34 | } # $props 35 | } 36 | 37 | 38 | # If no task is passed, list all defined (public) tasks. 39 | task default -depends ListTasks 40 | 41 | task ListTasks -alias l -description 'List all defined tasks.' { 42 | 43 | # !! Ideally, we'd just pass through to -docs, but as of psake v4.7.0 on 44 | # !! PowerShell Core v6.1.0-preview on at least macOS, the formatting is broken. 45 | # !! Sadly, -docs use Format-* cmdlets behind the scenes, so we cannot 46 | # !! directly transform its output and must resort to text parsing. 47 | (Invoke-psake -nologo -detailedDocs -notr | out-string -stream) | % { 48 | $prop, $val = $_ -split ' *: ' 49 | switch ($prop) { 50 | 'Name' { $name = $val } 51 | 'Alias' { $alias = $val } 52 | 'Description' { 53 | if ($name -notmatch '^_') { # ignore internal helper tasks 54 | [pscustomobject] @{ Name = $name; Alias = $alias; Description = $val } 55 | } 56 | } 57 | } 58 | } | Out-String | Write-Host -ForegroundColor Green 59 | 60 | } 61 | 62 | task Test -alias t -description 'Run all tests via Pester.' { 63 | 64 | if ($props.SkipTests) { Write-Verbose -Verbose 'Skipping tests, as requested.'; return } 65 | 66 | Assert ((Invoke-Pester -PassThru).FailedCount -eq 0) "Aborting, because at least one test failed." 67 | 68 | } 69 | 70 | task UpdateChangeLog -description "Ensure that the change-log covers the current version." { 71 | 72 | $changeLogFile = $props.Files.Changelog 73 | 74 | # Ensure that an entry for the (new) version exists in the change-log file. 75 | # If not present, add an entry *template*, containing a *placeholder*. 76 | ensure-ChangeLogHasEntryTemplate -Version (get-ThisModuleVersion) 77 | 78 | if (test-StillHasPlaceholders -LiteralPath $changeLogFile) { 79 | # Synchronously prompt to replace the placeholder with real information. 80 | Write-Verbose -Verbose "Opening $changeLogFile for editing to ensure that version to be released is covered by an entry..." 81 | edit-Sync $changeLogFile 82 | } 83 | 84 | # Make sure that all placeholders were actually replaced with real information. 85 | assert-HasNoPlaceholders -LiteralPath $changeLogFile 86 | 87 | } 88 | 89 | task InspectReadMe -description "Open README.md for synchronous editing; ensure that it contains no placeholders afterward." { 90 | 91 | $readMeFile = $props.Files.ReadMe 92 | 93 | edit-Sync $readMeFile 94 | 95 | # Make sure that all placeholders were actually replaced with real information. 96 | assert-HasNoPlaceholders -LiteralPath $readMeFile 97 | 98 | } 99 | 100 | 101 | task Publish -alias pub -depends _assertMasterBranch, _assertNoUntrackedFiles, Test, Version, UpdateChangeLog, InspectReadMe -description 'Publish to the PowerShell Gallery.' { 102 | 103 | $moduleVersion = get-ThisModuleVersion 104 | 105 | Write-Verbose -Verbose 'Committing...' 106 | # Use the change-log entry for the new version as the commit message. 107 | iu git add --update . 108 | iu git commit -m (get-ChangeLogEntry -Version $moduleVersion) 109 | 110 | # Note: 111 | # We could try to assert up front that the version to be published has a higher number than 112 | # the currently published one, with `(Find-Module $props.ModuleName).Version`. 113 | # It can be a tad slow, however. For now we rely on Publish-Module to fail if the condition 114 | # is not met. (Does it fail with a meaningful error message?) 115 | 116 | Write-Verbose -Verbose 'Creating and pushing tags...' 117 | # Create a tag for the new version 118 | iu git tag -f -a -m "Version $moduleVersion" "v$moduleVersion" 119 | # Update the generic 'pre'[release] and 'stable' tags to point to the same tag, as appropriate. 120 | # !! As of PowerShell Core v6.1.0-preview.2, PowerShell module manifests only support [version] instances 121 | # !! and therefore do not support prereleases. 122 | # ?? However, Publish-Module does have an -AllowPrerelease switch - but it's undocumented as of 22 May 2018. 123 | $isPrerelease = $False 124 | iu git tag -f ('stable', 'pre')[$isPrerelease] 125 | 126 | # Push the tags to the origin repo. 127 | iu git push -f origin master --tags 128 | 129 | # Final prompt before publishign to the PS gallery. 130 | assert-confirmed @" 131 | 132 | About to PUBLISH TO THE POWERSHELL GALLERY: 133 | 134 | Module: $($props.moduleName) 135 | Version: $moduleVersion 136 | 137 | IMPORTANT: Make sure that: 138 | * you've run ``Invoke-psake LocalPublish`` to publish the module locally. 139 | * you've waited for the changes to replicate to all VMs. 140 | * you've run ``Push-Location (Split-Path (Get-Module -ListAvailable $($props.moduleName)).Path); if (`$?) { Invoke-Pester }`` 141 | and verified that the TESTS PASS: 142 | * on ALL PLATFORMS and 143 | * on WINDOWS, both in PowerShell Core and Windows PowerShell 144 | 145 | Proceed? 146 | "@ 147 | 148 | # Copy the module to a TEMPORARY FOLDER for publishing, so that 149 | # the .git folder and other files not relevant at runtime can be EXCLUDED. 150 | # A feature request to have Publish-Module support exclusions directly, 151 | # via -Exclude, has since been implemented - see https://github.com/PowerShell/PowerShellGet/issues/191 152 | # IMPORTANT: For publishing to succeed, the temp. dir.'s name must match the module's. 153 | $tempPublishDir = Join-Path ([io.Path]::GetTempPath()) "$PID/$($props.ModuleName)" 154 | $null = New-Item -ItemType Directory -Path $tempPublishDir 155 | 156 | copy-forPublishing -DestinationPath $tempPublishDir 157 | 158 | try { 159 | # Note: -Repository PSGallery is implied. 160 | Publish-Module -Path $tempPublishDir -NuGetApiKey (get-NuGetApiKey) 161 | } finally { 162 | Remove-Item -Force -Recurse -LiteralPath $tempPublishDir 163 | } 164 | 165 | Write-Verbose -Verbose @" 166 | 167 | PUBLISHING SUCCEEDED. 168 | 169 | Note that it can take a few minutes for the new module [version] to appear in the gallery. 170 | 171 | URL: https://www.powershellgallery.com/packages/$($props.moduleName)" 172 | "@ 173 | 174 | } 175 | 176 | task LocalPublish -alias lpub -depends Test -description 'Publish locally, to the current-user module location.' { 177 | 178 | $targetParentPath = if ($env:MK_UTIL_FOLDER_PERSONAL) { 179 | "$env:MK_UTIL_FOLDER_PERSONAL/Settings/PowerShell/Modules" 180 | } else { 181 | if ($env:OS -eq 'Windows_NT') { "$HOME\Documents\{0}\Modules" -f ('WindowsPowerShell', 'PowerShell')[[bool]$IsCoreClr] } else { "$HOME/.local/share/powershell/Modules" } 182 | } 183 | 184 | $targetPath = Join-Path $targetParentPath (Split-Path -Leaf $PSScriptRoot) 185 | 186 | # Make sure the user confirms the intent. 187 | assert-confirmed @" 188 | 189 | About to PUBLISH LOCALLY to: 190 | 191 | $targetPath 192 | 193 | which will REPLACE the existing folder's content, if present. 194 | 195 | Proceed? 196 | "@ 197 | 198 | copy-forPublishing -DestinationPath $targetPath 199 | 200 | } 201 | 202 | task Commit -alias c -depends _assertNoUntrackedFiles -description 'Commit pending changes locally.' { 203 | 204 | if ((iu git status --porcelain).count -eq 0) { 205 | Write-Verbose -Verbose '(Nothing to commit.)' 206 | } else { 207 | Write-Verbose -Verbose "Committing changes to branch '$(iu git symbolic-ref --short HEAD)'; please provide a commit message..." 208 | iu git add --update . 209 | iu git commit 210 | } 211 | 212 | } 213 | 214 | task Push -depends Commit -description 'Commit pending changes locally and push them to the remote "origin" repository.' { 215 | iu git push origin (iu git symbolic-ref --short HEAD) 216 | } 217 | 218 | task Version -alias ver -description 'Show or bump the module''s version number.' { 219 | 220 | $htModuleMetaData = Import-PowerShellDataFile -LiteralPath $props.Files.Manifest 221 | $ver = [version] $htModuleMetaData.ModuleVersion 222 | 223 | Write-Host @" 224 | 225 | CURRENT version number: 226 | 227 | $ver 228 | "@ 229 | 230 | if (-not $props.SkipPrompts) { 231 | 232 | # Prompt for what version-number component should be incremented. 233 | $choices = 'Major', 'mInor', 'Patch', 'Retain', 'Abort' 234 | while ($True) { 235 | 236 | $ndx = read-HostChoice @" 237 | 238 | BUMP THE VERSION NUMBER 239 | "@ -Choices $choices 240 | 241 | Assert ($ndx -ne $choices.count -1) 'Aborted by user request.' 242 | if ($ndx -eq $choices.count -2) { 243 | Write-Warning "Retaining existing version $ver, as requested." 244 | $verNew = $ver 245 | break 246 | } else { 247 | # Prompt to confirm the resulting new version. 248 | $verNew = increment-version $ver -Property $choices[$ndx] 249 | $ndx = read-HostChoice @" 250 | Confirm the NEW VERSION NUMBER: 251 | 252 | $ver -> $verNew 253 | 254 | Proceed? 255 | "@ -Choice 'Yes', 'Revise' -DefaultChoiceIndex 0 256 | if ($ndx -eq 0) { 257 | break 258 | } 259 | } 260 | 261 | } 262 | 263 | # Update the module manifest with the new version number. 264 | if ($ver -ne $verNew) { 265 | update-ModuleManifestVersion -Path $props.Files.Manifest -ModuleVersion $verNew 266 | } 267 | 268 | # Add an entry *template* for the new version to the changelog file. 269 | ensure-ChangeLogHasEntryTemplate -Version $verNew 270 | 271 | } 272 | 273 | } 274 | 275 | task EditConfig -alias edc -description "Open the global configuration file for editing." { 276 | Invoke-Item -LiteralPath $props.Files.GlobalConfig 277 | } 278 | 279 | task EditManifest -alias edm -description "Open the module manifest for editing." { 280 | Invoke-Item -LiteralPath $props.Files.Manifest 281 | } 282 | 283 | task EditPsakeFile -alias edp -description "Open this psakefile for editing." { 284 | Invoke-Item -LiteralPath $PSCommandPath 285 | } 286 | 287 | #region == Internal helper tasks. 288 | 289 | # # Playground task for quick experimentation 290 | task _pg { 291 | get-NuGetApiKey -Prompt 292 | } 293 | 294 | task _assertMasterBranch { 295 | Assert ((iu git symbolic-ref --short HEAD) -eq 'master') "Must be on branch 'master'." 296 | } 297 | 298 | task _assertNoUntrackedFiles { 299 | Assert (-not ((iu git status --porcelain) -like '`?`? *')) 'Workspace must not contain untracked files.' 300 | } 301 | 302 | 303 | #endregion 304 | 305 | #region == Internal helper functions 306 | 307 | # Helper function to prompt the user for confirmation, unless bypassed. 308 | function assert-confirmed { 309 | param( 310 | [parameter(Mandatory)] 311 | [string] $Message 312 | ) 313 | 314 | if ($props.SkipPrompts) { Write-Verbose -Verbose 'Bypassing confirmation prompts, as requested.'; return } 315 | 316 | Assert (0 -eq (read-HostChoice $Message -Choices 'yes', 'abort')) 'Aborted by user request.' 317 | 318 | } 319 | 320 | # Invokes an external utility, asserting successful execution. 321 | # Pass the command as-is, as if invoking it directly; e.g.: 322 | # iu git push 323 | Set-Alias iu invoke-Utility 324 | function invoke-Utility { 325 | $exe, $argsForExe = $Args 326 | $ErrorActionPreference = 'Stop' # in case $exe isn't found 327 | & $exe $argsForExe 328 | if ($LASTEXITCODE) { Throw "$exe indicated failure (exit code $LASTEXITCODE; full command: $Args)." } 329 | } 330 | 331 | # Increment a [semver] or [version] instance's specified component. 332 | # Outputs an inremented [semver] or [version] instance. 333 | # If -Property is not specified, the patch / build level is incremented. 334 | # If the input version is not already a [version] or [semver] version, 335 | # [semver] is assumed, EXCEPT when: 336 | # * [semver] is not available (WinPS up to at least v5.1) 337 | # * a -Property name is passed that implies [version], namely 'Build' or 'Revision'. 338 | # Examples: 339 | # increment-version 1.2.3 -Property Minor # -> [semver] '1.3.3' 340 | # increment-version 1.2.3 -Property Revision # -> [version] '1.2.3.1' 341 | function increment-Version { 342 | 343 | param( 344 | [Parameter(Mandatory)] 345 | $Version 346 | , 347 | [ValidateSet('Major', 'Minor', 'Build', 'Revision', 'Patch')] 348 | [string] $Property = 'Patch' 349 | , 350 | [switch] $AssumeLegacyVersion # with string input, assume [version] rather than [semver] 351 | ) 352 | 353 | # If the version is passed as a string and property names specific to [version] 354 | # are used, assume [version] 355 | if ($Property -in 'Build', 'Revision') { $AssumeLegacyVersion = $True } 356 | 357 | # See if [semver] is supported in the host PS version (not in WinPS as of v5.1). 358 | $isSemVerSupported = [bool] $(try { [semver] } catch {}) 359 | 360 | if ($isSemVerSupported -and $Version -is [semver]) { 361 | $verObj = $Version 362 | } elseif ($Version -is [version]) { 363 | $verObj = $Version 364 | } else { 365 | $verObj = $null 366 | if ($isSemVerSupported -and -not $AssumeLegacyVersion) { 367 | $null = [semver]::TryParse([string] $Version, [ref] $verObj) 368 | } 369 | if (-not $verObj -and -not ([version]::TryParse([string] $Version, [ref] $verObj))) { 370 | Throw "Could not parse as a version: '$Version'" 371 | } 372 | } 373 | 374 | $arguments = 375 | ($verObj.Major, ($verObj.Major + 1))[$Property -eq 'Major'], 376 | ($verObj.Minor, ($verObj.Minor + 1))[$Property -eq 'Minor'] 377 | 378 | if ($isSemVerSupported -and $verObj -is [semver]) { 379 | 380 | if ($Property -eq 'Revision') { Throw "[semver] versions do not have a '$Property' property." } 381 | # Allow interchangeable use of 'Build' and 'Patch' to refer to the 3rd component. 382 | if ($Property -eq 'Build') { $Property = 'Patch' } 383 | 384 | $arguments += ($verObj.Patch, ($verObj.Patch + 1))[$Property -eq 'Patch'] 385 | 386 | } else { # [version] 387 | 388 | # Allow interchangeable use of 'Build' and 'Patch' to refer to the 3rd component. 389 | if ($Property -eq 'Patch') { $Property = 'Build' } 390 | 391 | if ($Property -in 'Build', 'Revision') { 392 | $arguments += [Math]::Max(0, $verObj.Build) + $(if ($Property -eq 'Build') { 1 } else { 0 }) 393 | } 394 | 395 | if ($Property -eq 'Revision') { 396 | $arguments += [Math]::Max(0, $verObj.Revision) + 1 397 | } 398 | 399 | } 400 | 401 | New-Object $verObj.GetType().FullName -ArgumentList $arguments 402 | 403 | } 404 | 405 | <# 406 | .SYNOPSIS 407 | Prompts for one value from an array of choices 408 | and returns the index of the array element 409 | that was chosen. 410 | 411 | #> 412 | function read-HostChoice { 413 | 414 | param( 415 | [string] $Message, 416 | [string[]] $Choices = ('yes', 'no'), 417 | [switch] $NoChoicesDisplay, 418 | [int] $DefaultChoiceIndex = -1, # LAST option is the default choice. 419 | [switch] $NoDefault # no default; i.e., disallow empty/blank input 420 | ) 421 | 422 | if ($DefaultChoiceIndex -eq -1) { $DefaultChoiceIndex = $Choices.Count - 1 } 423 | 424 | $choiceCharDict = [ordered] @{} 425 | foreach ($choice in $Choices) { 426 | $choiceChar = if ($choice -cmatch '\p{Lu}') { $matches[0] } else { $choice[0] } 427 | if ($choiceCharDict.Contains($choiceChar)) { Throw "Choices are ambiguous; make sure that each initial char. or the first uppercase char. is unique: $Choices" } 428 | $choiceCharDict[$choiceChar] = $null 429 | } 430 | [string[]] $choiceChars = $choiceCharDict.Keys 431 | 432 | if (-not $NoChoicesDisplay) { 433 | $i = 0 434 | [string[]] $choicesFormatted = foreach ($choice in $Choices) { 435 | [regex]::replace($choice, $choiceChars[$i], { param($match) '[' + $(if (-not $NoDefault -and $i -eq $DefaultChoiceIndex) { $match.Value.ToUpperInvariant() } else { $match.Value.ToLowerInvariant() }) + ']' }) 436 | ++$i 437 | } 438 | 439 | $Message += " ($($OFS=' / '; $choicesFormatted)): " 440 | } 441 | 442 | while ($true) { 443 | # TODO: add coloring to prompts. 444 | # Write-HostColored -NoNewline $Message 445 | Write-Host -NoNewline $Message 446 | $response = (Read-Host).Trim() 447 | $ndx = [Array]::FindIndex($choiceChars, [System.Predicate[string]]{ $Args[0] -eq $response }) 448 | if ($response -and $ndx -eq -1) { 449 | # As a courtesy, also allow the user to type a choice in full. 450 | $ndx = [Array]::FindIndex($Choices, [System.Predicate[string]]{ $Args[0] -eq $response }) 451 | } 452 | if ($ndx -ge 0) { # valid input 453 | break 454 | } elseif (-not $response -and -not $NoDefault) { # use default 455 | $ndx = $DefaultChoiceIndex 456 | break 457 | } 458 | Write-Warning "Unrecognized reponse. Please type one of the letters inside [...], followed by ENTER." 459 | } 460 | 461 | return $ndx 462 | } 463 | 464 | # Updates the specified module manifest with a new version number. 465 | # Note: We do NOT use Update-ModuleManifest, because it rewrites the 466 | # file in a manner that wipes out custom comments. 467 | # !! RELIES ON EACH PROPERTY BEING DEFINED ON ITS OWN LINE. 468 | function update-ModuleManifestVersion { 469 | param( 470 | [Parameter(Mandatory)] 471 | [Alias('Path')] 472 | [string] $LiteralPath 473 | , 474 | [Parameter(Mandatory)] 475 | [Alias('ModuleVersion')] 476 | [version] $Version 477 | ) 478 | 479 | $lines = Get-Content -LiteralPath $LiteralPath 480 | 481 | $lines -replace '^(\s*ModuleVersion\s*=).*', ('$1 ''{0}''' -f $Version) | Set-Content -Encoding ascii $LiteralPath 482 | } 483 | 484 | # Reads the global config (settings) and returns the settings as a hashtable. 485 | # Note: Analogous to use in Git, "global" refers to *current-user-global* settings. 486 | function get-GlobalConfig { 487 | if (-not (Test-Path $props.Files.globalConfig)) { 488 | Write-Warning "No global settings filefound: $($props.Files.GlobalConfig)" 489 | @{} 490 | } else { 491 | Import-PowerShellDataFile -LiteralPath $props.Files.globalConfig 492 | } 493 | } 494 | 495 | function get-NuGetApiKey { 496 | param( 497 | [switch] $Prompt 498 | ) 499 | 500 | # Read the user's global configuration. 501 | $htConfig = get-GlobalConfig 502 | 503 | if ($Prompt -or -not $htConfig.NuGetApiKey) { 504 | 505 | # Prompt the user. 506 | $configPsdFile = $props.Files.globalConfig 507 | # e.g. 5ecf36c5-437f-0123-7654-c91df8f79ca4 508 | $regex = '^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$' 509 | while ($true) { 510 | $nuGetApiKey = (Read-Host -Prompt "Enter your NuGet API Key (will be saved in '$configPsdFile')").Trim() 511 | if ($nuGetApiKey -match $regex) { break } 512 | Write-Warning "Invalid key specified; a vaid key must match regex '$regex'. Please try again." 513 | } 514 | 515 | # Update or create the config file. 516 | if (-not (Test-Path -LiteralPath $configPsdFile)) { # create on demand. 517 | @" 518 | <# 519 | Global configuration file for PowerShell module projects created with New-ModuleProject 520 | 521 | IMPORTANT: 522 | * Keep each entry on its own line. 523 | * Save this file as BOM-less UTF-8 or ASCII and use only ASCII characters. 524 | 525 | #> 526 | @{ 527 | NuGetApiKey = '$nuGetApiKey' 528 | } 529 | "@ | Set-Content -Encoding Ascii -LiteralPath $configPsdFile 530 | } else { # update 531 | $lines = Get-Content -LiteralPath $configPsdFile 532 | 533 | $lines -replace '^(\s*NuGetApiKey\s*=).*', ('$1 ''{0}''' -f $nuGetApiKey) | Set-Content -Encoding ascii $configPsdFile 534 | 535 | } 536 | 537 | $htConfig.NuGetApiKey = $nuGetApiKey 538 | } # if 539 | 540 | # Outptut the key. 541 | $htConfig.NuGetApiKey 542 | } 543 | 544 | # Copy this project's file for publishing to the specified dir., excluding dev-only files. 545 | function copy-forPublishing { 546 | param( 547 | [Parameter(Mandatory)] 548 | [string] $DestinationPath 549 | ) 550 | 551 | # Create the target folder or, if it already exists, remove its *contents*. 552 | if (Test-Path -LiteralPath $DestinationPath) { 553 | Remove-Item -Force -Recurse -Path $DestinationPath/* 554 | } else { 555 | New-Item -ItemType Directory -Path $DestinationPath 556 | } 557 | 558 | # Copy this folder's contents recursively, but exclude the .git subfolder, the .gitignore file, and the psakefile. 559 | Copy-Item -Recurse -Path "$($PSScriptRoot)/*" -Destination $DestinationPath -Exclude '.git', '.gitignore', 'psakefile.ps1' 560 | 561 | Write-Verbose -Verbose "'$PSScriptRoot' copied to '$DestinationPath'" 562 | 563 | } 564 | 565 | # Ensure the presence of an entry *template* for the specified version in the specified changelog file. 566 | function ensure-ChangeLogHasEntryTemplate { 567 | 568 | param( 569 | [parameter(Mandatory)] [version] $Version 570 | ) 571 | 572 | $changeLogFile = $props.Files.ChangeLog 573 | $content = Get-Content -Raw -LiteralPath $changeLogFile 574 | if ($content -match [regex]::Escape("* **v$Version**")) { 575 | Write-Verbose "Changelog entry for $Version is already present in $changeLogFile" 576 | } else { 577 | Write-Verbose "Adding changelog entry for $Version to $changeLogFile" 578 | $parts = $content -split '()' 579 | if ($parts.Count -ne 3) { Throw 'Cannot find (single) marker comment in $changeLogFile' } 580 | $newContent = $parts[0] + $parts[1] + "`n`n* **v$Version** ($([datetime]::now.ToString('yyyy-MM-dd'))):`n * [???] " + $parts[2] 581 | # Update the input file. 582 | # Note: We write the file as BOM-less UTF-8. 583 | [IO.File]::WriteAllText((Convert-Path -LiteralPath $changeLogFile), $newContent) 584 | } 585 | 586 | } 587 | 588 | # Indicates if the specified file (still) contains placeholders (literal '???' sequences). 589 | function test-StillHasPlaceholders { 590 | param( 591 | [parameter(Mandatory)] [string] $LiteralPath 592 | ) 593 | (Get-Content -Raw $LiteralPath) -match [regex]::Escape('???') 594 | } 595 | 596 | # Fails, if the specified file (still) contains placeholders. 597 | function assert-HasNoPlaceholders { 598 | param( 599 | [Parameter(Mandatory)] [string] $LiteralPath 600 | ) 601 | Assert (-not (test-StillHasPlaceholders -LiteralPath $LiteralPath)) "Aborting, because $LiteralPath still contains placeholders in lieu of real information." 602 | } 603 | 604 | # Retrieves this module's version number from the module manifest as a [version] instance. 605 | function get-ThisModuleVersion { 606 | [version] (Import-PowerShellDataFile $props.Files.Manifest).ModuleVersion 607 | } 608 | 609 | # Synchronously open the specified file(s) for editing. 610 | function edit-Sync { 611 | 612 | [CmdletBinding(DefaultParameterSetName='Path')] 613 | param( 614 | [Parameter(ParameterSetName='Path', Mandatory=$True, Position=0)] [SupportsWildcards()] [string[]] $Path, 615 | [Parameter(ParameterSetName='LiteralPath', Mandatory=$True, Position=0)] [string[]] $LiteralPath 616 | ) 617 | 618 | if ($Path) { 619 | $paths = Resolve-Path -EA Stop -Path $Path 620 | } else { 621 | $paths = Resolve-Path -EA Stop -LiteralPath $LiteralPath 622 | } 623 | 624 | # RESPECT THE EDITOR CONFIGURED FOR GIT. 625 | $edCmdLinePrefix = git config core.editor # Note: $LASTEXITCODE will be 1 if no editor is defined. 626 | # Note: the editor may be defined as an executable *plus options*, such as `code -n -w`. 627 | $edExe, $edOpts = -split $edCmdLinePrefix 628 | if (-not $edExe) { # If none is explicitly configured, FALL BACK TO GIT'S DEFAULT. 629 | # Check env. variables. 630 | $edExe = foreach ($envVarVal in $env:EDITOR, $env:VISUAL) { 631 | if ($envVarVal) { $envVarVal; break } 632 | } 633 | # Look for gedit, vim, vi 634 | # Note: Git will only use `gedit` by default if that default is compiled into Git's binary. 635 | # This is the case on Ubuntu, for instance. 636 | # !! Therefore, it's possible for us to end up using a different editor than Git, such as on Fedora. 637 | $edExe = foreach ($exe in 'gedit', 'vim', 'vi') { 638 | if (Get-Command -ErrorAction Ignore $exe) { $exe; break } 639 | } 640 | # If no suitable editor was found and when running on Windows, 641 | # see if vim.exe, installed with Git but not present in $env:PATH, can be located, as a last resort. 642 | if (-not $edExe -and ($env:OS -ne 'Windows_NT' -or -not (Test-Path ($edExe = "$env:PROGRAMFILES/Git/usr/bin/vim.exe")))) { 643 | # We give up. 644 | Throw "No suitable text editor for synchronous editing found." 645 | } 646 | # Notify the user that no "friendly" editor is configured. 647 | # TODO: We could offer to perform this configuration by prompting the user 648 | # to choose one of the installed editors, if present. 649 | Write-Warning @" 650 | 651 | NO "FRIENDLY" TEXT EDITOR IS CONFIGURED FOR GIT. 652 | 653 | To define one, use one of the following commands, depending on what's available 654 | on your system: 655 | 656 | * Visual Studio Code: 657 | 658 | git config --global core.editor 'code -n -w' 659 | 660 | * Atom: 661 | 662 | git config --global core.editor 'atom -n -w' 663 | 664 | * Sublime Text: 665 | 666 | git config --global core.editor 'subl -n -w' 667 | 668 | "@ 669 | } 670 | 671 | # # Editor executables in order of preference. 672 | # # Use the first one found to be installed. 673 | # $edExes = 'code', 'atom', 'subl', 'gedit', 'vim', 'vi' # code == VSCode 674 | # $edExe = foreach ($exe in $edExes) { 675 | # if (Get-Command -ErrorAction Ignore $exe) { $exe; break } 676 | # } 677 | # # If no suitable editor was found and when running on Windows, 678 | # # see if vim.exe, installed with Git but not in $env:PATH, can be located. 679 | # if (-not $edExe -and ($env:OS -ne 'Windows_NT' -or -not (Test-Path ($edExe = "$env:PROGRAMFILES/Git/usr/bin/vim.exe")))) { 680 | # Throw "No suitable text editor for synchronous editing found." 681 | # } 682 | 683 | # # For VSCode, Atom, SublimeText, ensure synchronous execution in a new window. 684 | # # For gedit and vim / vi that is the befault behavior, so no options needed. 685 | # $opts = @() 686 | # if ($edExe -in 'code', 'atom', 'subl') { 687 | # $opts = '--new-window', '--wait' 688 | # } 689 | 690 | # Invoke the editor synchronously. 691 | & $edExe $edOpts $paths 692 | 693 | } 694 | 695 | # Extracts the change-log entry (multi-line block) for the specified version. 696 | function get-ChangeLogEntry { 697 | param( 698 | [Parameter(Mandatory)] [version] $Version 699 | ) 700 | $changeLogFile = $props.Files.ChangeLog 701 | $content = Get-Content -Raw -LiteralPath $changeLogFile 702 | $entriesBlock = ($content -split '')[-1] 703 | if ($entriesBlock -notmatch ('(?sm)' + [regex]::Escape("* **v$Version**") + '.+?(?=\r?\n' + [regex]::Escape('* **v') + ')')) { 704 | Throw "Failed to extract change-long entry for version $version." 705 | } 706 | # Output the entry. 707 | $Matches[0] 708 | } 709 | 710 | #endregion 711 | --------------------------------------------------------------------------------