Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PowerShell script wrapped in batch file is executed only halfway

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?

like image 526
ygoe Avatar asked Jul 30 '21 16:07

ygoe


4 Answers

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."
like image 56
Anon Coward Avatar answered Oct 21 '22 23:10

Anon Coward


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.

  • It is indeed the combination of the multi-line 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 %*

    • Unfortunately, PowerShell only accepts files with extension .ps1 as a -file argument, so a copy of the batch file with that extension is created.
    • In the code above, that copy is created in the same directory as the batch file, but that could be tweaked (e.g., you could create it in %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, replace
    Get-Content -Raw -LiteralPath ""%~f0"" with
    Get-Content LiteralPath ""%~f0"" | Out-String

like image 40
mklement0 Avatar answered Oct 21 '22 22:10

mklement0


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."
like image 1
Compo Avatar answered Oct 21 '22 23:10

Compo


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.

like image 1
ygoe Avatar answered Oct 21 '22 21:10

ygoe