How do I have a PowerShell script embedded within the same file as a Windows batch script?
I know this kind of thing is possible in other scenarios:
My current preference for this task is a polyglot header that works much the same way as mklement0's first solution:
<# :cmd header for PowerShell script
@ set dir=%~dp0
@ set ps1="%TMP%\%~n0-%RANDOM%-%RANDOM%-%RANDOM%-%RANDOM%.ps1"
@ copy /b /y "%~f0" %ps1% >nul
@ powershell -NoProfile -ExecutionPolicy Bypass -File %ps1% %*
@ del /f %ps1%
@ goto :eof
#>
# Paste arbitrary PowerShell code here.
# In this example, all arguments are echoed.
$Args | % { 'arg #{0}: [{1}]' -f ++$i, $_ }
I prefer to lay the cmd header out as multiple lines with a single command on each one, for a number of reasons. First, I think it's easier to see what's going on: the command lines are short enough not to run off the right of my edit windows, and the column of punctuation on the left marks it visually as the header block that the horribly abused label on the first line says it is. Second, the del
and goto
commands are on their own lines, so they will still run even if something really funky gets passed as a script argument.
I have come to prefer solutions that make a temporary .ps1
file to those that rely on Invoke-Expression
, purely because PowerShell's inscrutable error messages will then at least include meaningful line numbers.
The time it takes to make the temp file is usually completely swamped by the time it takes PowerShell itself to lumber into action, and 128 bits worth of %RANDOM%
embedded in the temp file's name pretty much guarantees that multiple concurrent scripts won't ever stomp each other's temp files. The only real downside to the temp file approach is possible loss of information about the directory the original cmd script was invoked from, which is the rationale for the dir
environment variable created on the second line.
Obviously it would be far less annoying for PowerShell not to be so anal about the filename extensions it will accept on script files, but you go to war with the shell you have, not the shell you wish you had.
Speaking of which: as mklement0 observes,
# BREAKS, due to the `&` inside \"...\"
sample.cmd "A \"rock & roll\" life style"
This does indeed break, due to cmd.exe
's completely worthless argument parsing. I've generally found that the less work I do to try to hide cmd's many limitations, the fewer unanticipated bugs I cause myself down the line (I am sure I could come up with arguments containing parentheses that would break mklement0's otherwise impeccable ampersand escaping logic, for example). Less painful, in my view, just to bite the bullet and use something like
sample.cmd "A \"rock ^^^& roll\" life style"
The first and third ^
escapes get eaten when that command line is initially parsed; the second one survives to escape the &
embedded in the command line passed to powershell.exe
. Yes, this is ugly. Yes, it does make it harder to pretend that cmd.exe
isn't what gets first crack at the script. Don't worry about it. Document it if it matters.
In most real-world applications, the &
issue is moot anyway. Most of what's going to get passed as arguments to a script like this will be pathnames that arrive via drag and drop. Windows will quote those, which is enough to protect spaces and ampersands and in fact anything other than quotes, which aren't allowed in Windows pathnames anyway.
Don't even get me started on Vinyl LP's, 12"
turning up in a CSV file.
Without fully understanding your question, my suggestion would be something like:
@echo off
set MYSCRIPT="some cool powershell code"
powershell -c %MYSCRIPT%
or better yet
@echo off
set MYSCRIPTPATH=c:\work\bin\powershellscript.ps1
powershell %MYSCRIPTPATH%
Another sample batch+PowerShell script... It's simpler than the other proposed solution, and has characteristics that none of them can match:
This sample displays the language transitions, and the PowerShell side displays the list of arguments it received from the batch side.
<# :# PowerShell comment protecting the Batch section
@echo off
:# Disabling argument expansion avoids issues with ! in arguments.
setlocal EnableExtensions DisableDelayedExpansion
:# Prepare the batch arguments, so that PowerShell parses them correctly
set ARGS=%*
if defined ARGS set ARGS=%ARGS:"=\"%
if defined ARGS set ARGS=%ARGS:'=''%
:# The ^ before the first " ensures that the Batch parser does not enter quoted mode
:# there, but that it enters and exits quoted mode for every subsequent pair of ".
:# This in turn protects the possible special chars & | < > within quoted arguments.
:# Then the \ before each pair of " ensures that PowerShell's C command line parser
:# considers these pairs as part of the first and only argument following -c.
:# Cherry on the cake, it's possible to pass a " to PS by entering two "" in the bat args.
echo In Batch
PowerShell -c ^"Invoke-Expression ('^& {' + [io.file]::ReadAllText(\"%~f0\") + '} %ARGS%')"
echo Back in Batch. PowerShell exit code = %ERRORLEVEL%
exit /b
###############################################################################
End of the PS comment around the Batch section; Begin the PowerShell section #>
echo "In PowerShell"
$Args | % { "PowerShell Args[{0}] = '$_'" -f $i++ }
exit 0
Note that I use :# for batch comments, instead of :: as most other people do, as this actually makes them look like PowerShell comments. (Or like most other scripting languages comments actually.)
I like Jean-François Larvoire's solution very much, especially for his handling of Arguments and passing them to the powershell-script diredtly (+1 added).
But it has one flaw. AS I do npt have the reputatioin to comment, I post the correction as a new solution.
The script name as argument for Invoke-Expression in double-quotes will not work when the script-name contains a $
-character, as this will be evaluated before the file contents is loaded. The simplest remedy is to replace the double quotes:
PowerShell -c ^"Invoke-Expression ('^& {' + [io.file]::ReadAllText('%~f0') + '} %ARGS%')"
Personally, I rather prefer using get-content
with the -raw
option, as to me this is more powershell'ish:
PowerShell -c ^"Invoke-Expression ('^& {' + (get-content -raw '%~f0') + '} %ARGS%')"
But that is, of course just my personal opinion. ReadAllText works
just perfectly.
For completeness, the corrected script:
<# :# PowerShell comment protecting the Batch section
@echo off
:# Disabling argument expansion avoids issues with ! in arguments.
setlocal EnableExtensions DisableDelayedExpansion
:# Prepare the batch arguments, so that PowerShell parses them correctly
set ARGS=%*
if defined ARGS set ARGS=%ARGS:"=\"%
if defined ARGS set ARGS=%ARGS:'=''%
:# The ^ before the first " ensures that the Batch parser does not enter quoted mode
:# there, but that it enters and exits quoted mode for every subsequent pair of ".
:# This in turn protects the possible special chars & | < > within quoted arguments.
:# Then the \ before each pair of " ensures that PowerShell's C command line parser
:# considers these pairs as part of the first and only argument following -c.
:# Cherry on the cake, it's possible to pass a " to PS by entering two "" in the bat args.
echo In Batch
PowerShell -c ^"Invoke-Expression ('^& {' + (get-content -raw '%~f0') + '} %ARGS%')"
echo Back in Batch. PowerShell exit code = %ERRORLEVEL%
exit /b
###############################################################################
End of the PS comment around the Batch section; Begin the PowerShell section #>
echo "In PowerShell"
$Args | % { "PowerShell Args[{0}] = '$_'" -f $i++ }
exit 0