I'd like to wrap a PowerShell script into a batch file to finally make it executable for anybody and in a single distributed file. My idea was to begin a file with CMD commands that read the file itself, skipping the first couple lines and piping the rest to powershell, then let the batch file end. In Bash that would be an easy task with short readable commands, but you can see from the numerous tricks that Windows has big trouble with this already. That's how it looks like:
@echo off
(for /f "skip=4 delims=" %%a in ('type %0') do @echo.%%a) |powershell -noprofile -noninteractive -
goto :EOF
---------- BEGIN POWERSHELL ----------
write-host "Hello PowerShell!"
if ($true)
{
write-host "TRUE"
}
write-host "Good bye."
The problem is, the PowerShell script doesn't execute completely, it stops after the first written line. I could make it work some more, depending on the script itself, but couldn't find any pattern here.
If you pipe the result from line 2 to a file or to a cat
process (if you have unix tools installed, Windows can't even do that on its own), you can see the complete PowerShell part of the file, so it's not cut off or something. PowerShell just doesn't want to execute the script here.
Copy the file, remove the first 4 lines and rename it to *.ps1, then call this:
powershell -ExecutionPolicy unrestricted -File FILENAME.ps1
And it'll execute completely. So it's not the PowerShell script content either. What is it that lets powershell end prematurely in this case?
You can hide the batch file portion of the script from powershell with a comment block, and then run the script as-is without having to have a batch file modify it:
<# :
@echo off
powershell -command "Invoke-Expression (Get-Content '%0' -Raw)"
goto :EOF
#>
write-host "Hello PowerShell!"
if ($true)
{
write-host "TRUE"
}
write-host "Good bye."
Unfortunately, piping script content to powershell.exe
/ pwsh
, i.e. providing PowerShell code via stdin, -
- whether via -Command
(-c
) (the powershelle.exe
default) or -File
(-f
) (the pwsh
default) - has serious limitations: It displays pseudo-interactive behavior, situationally requires two trailing newlines to terminate a statement, and lacks support for argument-passing; see GitHub issue #3223.
if
statement with the lack of an extra newline (empty line) following it that causes processing of your script to execute prematurely, because the end of that multi-line statement (which includes all subsequent statement) is not recognized. The added problem is that for /f
removes empty lines, so adding one after the closing }
does not work; While you could use a non-empty, all-whitespace line instead (a single space will do), which doesn't get removed, such obscure workarounds - required after every multi-line statement - are not worth the trouble, so the stdin-based approach is best avoided.
Building on Anon Coward's excellent trick for hiding the batch-file portion of the file from PowerShell, let me offer the following improvements:
Using a copy of the batch file with extension .ps1
appended, passed to the PowerShell CLI's -file
(-f
) parameter:
<# ::
@echo off
copy /y "%~f0" "%~dpn0.ps1" > NUL
powershell -executionpolicy bypass -noprofile -file "%~dpn0.ps1" %*
exit /b %ERRORLEVEL%
#>
# ---------- BEGIN POWERSHELL ----------
"Hello PowerShell!"
"Args received:`n$($args.ForEach({ "[$_]" }))"
if ($true)
{
"TRUE"
}
"Good bye."
Invoking the script with -file
preserves the usual PowerShell-script experience, with respect to the script reporting its own file name and path and, perhaps more importantly, robustly supporting arguments passed through with %*
.ps1
as a -file
argument, so a copy of the batch file with that extension is created.%TEMP%
and/or auto-delete the copy after having called PowerShell; see this answer).exit /b %ERRORLEVEL%
is used to exit the batch file, so as to ensure that PowerShell's exit code is passed through.
Extending Anon Coward's no-extra-file,Invoke-Expression
-based solution to support robust argument-passing:
This approach avoids the need for a (temporary) copy of the batch file with extension .ps1
; a slight drawback is that the Invoke-Expression
-based call will make the code report the empty string as the script's own file path (via the automatic $PSCommandPath
variable) and directory (via $PSScriptRoot
). However, you could use $batchFilePath = ([Environment]::CommandLine -split '""')[1]
to get the batch file's full path.
<# ::
@echo off
set __args=%*
powershell -executionpolicy bypass -noprofile -c Invoke-Expression ('. { ' + (Get-Content -Raw -LiteralPath ""%~f0"") + ' }' + $env:__args)
exit /b %ERRORLEVEL%
#>
# ---------- BEGIN POWERSHELL ----------
"Hello PowerShell!"
"Args received:`n$($args.ForEach({ "[$_]" }))"
if ($true)
{
"TRUE"
}
"Good bye."
An auxiliary environment variable, %__args%
/ $env:__args
is used to pass all arguments (%*
) through to PowerShell, where its value is incorporated into the string passed to Invoke-Expression
; note how the file content is enclosed in . { ... }
, i.e. a call via script block, so that the code supports receiving arguments.
Note how %~f0
, the full file path of the batch file, is enclosed in ""...""
(which PowerShell ultimately sees as "..."
); using "
- rather than '
-quoting is slightly more robust, because file paths are permitted to contain '
chars. themselves, but not "
.
If you still need PowerShell v2 support, where Get-Content
's -Raw
switch isn't supported, replaceGet-Content -Raw -LiteralPath ""%~f0""
withGet-Content LiteralPath ""%~f0"" | Out-String
This method is quite similar to Anon Coward's, but without the reliance upon the -Raw
option, which was introduced in powershell-3.0:
<# :
@%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile^
-NoLogo -Command "$input | &{ [ScriptBlock]::Create("^
"(Get-Content -LiteralPath \"%~f0\") -Join [Char]10).Invoke() }"
@Pause
@GoTo :EOF
#>
Write-Host "Hello PowerShell!"
If ($True)
{
Write-Host "TRUE"
}
Write-Host "Goodbye."
I have split the long PowerShell command line over multiple for better reading, but it is not necessary:
<# :
@%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NoLogo -Command "$input | &{ [ScriptBlock]::Create((Get-Content -LiteralPath \"%~f0\") -Join [Char]10).Invoke() }"
@Pause
@GoTo :EOF
#>
Write-Host "Hello PowerShell!"
If ($True)
{
Write-Host "TRUE"
}
Write-Host "Goodbye."
Thank you for the helpful answers! Just for reference, this is what I put together from the provided sources:
<# :
@echo off & setlocal & set __args=%* & %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -Command Invoke-Expression ('. { ' + (Get-Content -LiteralPath ""%~f0"" -Raw) + ' }' + $env:__args) & exit /b %ERRORLEVEL%
#>
param([string]$name = "PowerShell")
write-host "Hello $name!"
write-host "You said:" $args
if ($args)
{
write-host "This is true."
}
else
{
write-host "I don't like that."
exit 1
}
write-host "Good bye."
The batch part had a very long line already so I thought I'd just stuff all the boilerplate code in a single line.
In this edit, based on mklement0's edited answer, the commandline arguments handling is pretty complete. Examples:
ps-script.cmd 'Donald Duck' arg1 arg2
ps-script.cmd arg1 arg2 -name "Donald Duck"
The return code (errorlevel) is only visible if the batch file is executed with call
, like this:
call ps-script.cmd some args && echo OK
call ps-script.cmd && echo OK
Without the call
, the return value is always 0 and "OK" is always displayed. This is an often-forgotten limitation of CMD and batch files.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With