6

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?

ygoe
  • 18,655
  • 23
  • 113
  • 210
  • Oh, I thought it'd pipe the whole thing as one. How can I get that? This fails the same way: `type pscmd.ps1 |powershell -noprofile -noninteractive -` Does the Windows pipe operator always work line by line? Or do I need to tell powershell to wait for it all and then start? Bash on Linux can be invoked this way just fine. – ygoe Jul 30 '21 at 16:56
  • I think the problem is caused by the fact that a pipe initiates a new Command Prompt instance for either side… – aschipfl Jul 30 '21 at 17:18
  • There are situations where multiple files get torn apart. Or imagine a configuration script or similar, to be downloaded from a website. Should I instruct the user to download two separate files and keep them in the same directory? Or first unzip an archive? A single file is so much easier to handle. – ygoe Jul 30 '21 at 20:48

4 Answers4

10

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."
Anon Coward
  • 9,784
  • 3
  • 26
  • 37
  • Cool, problem solved much more elegantly! Also works for files in directories that contain an apostrophe in the path (the single-quotes made me curious). – ygoe Jul 30 '21 at 17:13
  • 2
    Nice trick. I suggest using `%~f0` instead of `%0`, however, to ensure that the batch file is targeted by its full path, so that the invocation also works when the working dir. happens not to be the one in which the batch file is located. A potential challenge is to pass arguments through, especially if they're enclosed in double quotes. – mklement0 Jul 30 '21 at 17:24
  • @mklement0 That doesn't seem to be necessary. `%0` works as well if the batch file is in another directory. It has the relative path then which is sufficient to find the file. – ygoe Jul 30 '21 at 19:29
  • 2
    @ygoe, it's only not necessary if you invoke the batch file with a _path_ (e.g., `.\foo.cmd`, or `C:\path\to\foo.cmd`), but it _is_ necessary if the batch file is placed in the system's `%PATH%` and is invoked _by name only_ (e.g., `foo`), at least from `cmd.exe` (PowerShell actually resolves that a full path behind the scenes, so `%0` sees the full path in that scenario.) – mklement0 Jul 30 '21 at 19:43
  • 1
    @mklement0 I understand and can confirm. While I haven't thought about using this technique for batch files in the %path%, I'll keep that `%~f0` modification anyway, just in case. Makes it more robust. – ygoe Jul 30 '21 at 20:19
5

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 (reasonably 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 & setlocal
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.

    • Caveat: This means that the arguments that were originally passed to your batch file are then interpreted with PowerShell syntax:
      • This breaks with arguments that have embedded " characters, such as "3\" of snow"; ditto for an unquoted argument that contains an unbalanced number of ' chars. (e.g., 6')

      • Even if the call doesn't break outright, the argument values can change, such as in the case of $-prefixed substrings that the batch file would treat literally (e.g., "amount: $3"); also, ' chars. have syntactic function in PowerShell (e.g., 'aha' would turn into verbatim aha, with the quotes removed), and ` chars. are interpreted as escape chars.

  • 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

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    Wow, I've just seen your last edit. That's impressive. Please see [my answer](https://stackoverflow.com/a/68597041/143684) for the complete result with all the parts I found now. – ygoe Jul 30 '21 at 21:30
1

This method is quite similar to Anon Coward's, but without the reliance upon the -Raw option, which was introduced in :

<# :
@%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."
Compo
  • 36,585
  • 5
  • 27
  • 39
1

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.

ygoe
  • 18,655
  • 23
  • 113
  • 210
  • Nice; good point about the need for `call`. As an aside: There's a related problem when using `exit /b` with an _implied_ exit code when a batch file is called _from PowerShell_, which requires `cmd /c 'ps-script.cmd & exit'` as a workaround - see [this answer](https://stackoverflow.com/a/66250528/45375). – mklement0 Jul 30 '21 at 21:55