目的
Windowsのバッチファイルの中にPowerShellスクリプトを埋め込みます。
背景
バッチファイルとしてもPowerShellスクリプトとしても正しく動作するファイルを作成することができます(英語では polyglot scriptなどと呼ばれます)。そして、そのバッチファイル部分に自身をPowerShellスクリプトとして起動し直すコマンドを書くことで、いずれの起動方法でもPowerShellスクリプトを実行できるようになります。ただし、Windowsでは拡張子とアプリが紐づけられているため、バッチファイルは拡張子が.batまたは.cmd、PowerShellスクリプトは拡張子が.ps1でないと実行できません。
方法1. スクリプトブロックとして実行
<# : by earthdiver1
@echo off & setlocal EnableDelayedExpansion
set BATCH_ARGS=%*
for %%A in (!BATCH_ARGS!) do set "ARG=%%~A" & set "ARG=!ARG:'=''!" & set "PWSH_ARGS=!PWSH_ARGS! "'!ARG!'""
endlocal & Powershell -NoProfile -Command "$input|&([ScriptBlock]::Create((gc '%~f0'|Out-String)))" %PWSH_ARGS%
pause & exit/b
: #>
以下にPowerShellスクリプトを書く
欠点:バッチファイルとして実行した際に\$PSScriptRoot、\$PSCommandPath、\$MyInvocation.MyCommand.Path が空になります。5行目を以下に置き換えれば、scriptスコープで\$PSScriptRoot、\$PSCommandPathを参照できるようになります(localスコープは空のまま)1。
endlocal & Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|&([ScriptBlock]::Create((gc '%~f0'|Out-String)))" %PWSH_ARGS%
方法2. 拡張子が.ps1のTemporaryなハードリンクを作成
<# : by earthdiver1
@echo off
set "BATFILE=%~f0"
set "PS1FILE=%BATFILE:.bat=.ps1%"
mklink/h "%PS1FILE%" "%BATFILE%" >NUL
setlocal EnableDelayedExpansion
set BATCH_ARGS=%*
for %%A in (!BATCH_ARGS!) do set "ARG=%%~A" & set "PWSH_ARGS=!PWSH_ARGS! "!ARG!""
endlocal & PowerShell -NoProfile -ExecutionPolicy Bypass -File "%PS1FILE%" %PWSH_ARGS%
del %PS1FILE%
pause & exit/b
: #>
以下にPowerShellスクリプトを書く
あるいは
<# : by earthdiver1
@echo off
set "BATFILE=%~f0"
set "PS1FILE=%BATFILE:.bat=.ps1%"
mklink/h "%PS1FILE%" "%BATFILE%" >NUL
setlocal EnableDelayedExpansion
set BATCH_ARGS=%*
for %%A in (!BATCH_ARGS!) do set "ARG=%%~A" & set "ARG=!ARG:'=''!" & set "PWSH_ARGS=!PWSH_ARGS! "'!ARG!'""
endlocal & PowerShell -NoProfile -ExecutionPolicy Bypass -Command "%PS1FILE%" %PWSH_ARGS%
del %PS1FILE%
pause & exit/b
: #>
以下にPowerShellスクリプトを書く
欠点:一時的にではあるが別ファイルを生成してしまいます。
備考
上記の2例はいずれも一行目がバッチファイルとしてはラベル行と見なされてエラーにならないところがミソ。
引数に含まれる記号等のクォートが不要な場合は、下の1行をバッチファイルの先頭につけることでPowerShellスクリプトをバッチファイルに変換することが可能2ですが、上記の例と異なり、この場合はPowerShellスクリプトとしては正しく動作しないファイルとなります(先頭行に加えた1行目がPowerShell言語とは認識されない)。
@Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|&([ScriptBlock]::Create((gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" %* & pause & exit/b
注意
-
exit/b
の前にpause
を入れるかどうかはお好みで(バッチ処理で使う場合は除いてください)。 - 標準設定では、PowerShell スクリプト(.ps1ファイル)をすぐには実行できないようになっています(事前に実行ポリシーの変更が必要3)。当然、ダブルクリックでの起動も制限されます。 これは、マルウェアなどの危険なスクリプトの不用意な実行を防ぐというセキュリティ上の配慮によるものです。上記の方法を使うとダブルクリックでの起動も可能になりますので、安全性が確認されている PowerShell スクリプトのみに適用してください(スクリプトの加工・修正を伴いますので、中身を全く確認せずに実行することはないと思いますが念のため...)。
方法1の改良版(特殊文字対応版) 2021.11.20更新
行数が増えてしまいましたが、引数に色々な特殊文字が含まれていてもできる限り4正常動作するよう方法1のスクリプトを変更しました。(バッチファイルの場合と同様に)二重引用符などで囲まれた文字列は引用符も含めて PowerShell の引数にセットされますのでご注意ください5。
<# : by earthdiver1
@echo off
set BATCH_ARGS=%*
setlocal EnableDelayedExpansion
set "BATCH_ARGS=!BATCH_ARGS:%%=%%%%!"
set "PWSH_ARGS=" & call :quote_args !BATCH_ARGS!
Powershell -NoProfile -Command "$input|&([ScriptBlock]::Create((gc '%~f0'|Out-String)))" !PWSH_ARGS!
endlocal
pause & exit/b
:quote_args
set "BATCH_ARG=" & call set "BATCH_ARG=%%1" 2>nul
if defined BATCH_ARG goto :arg_exists
set BATCH_ARG=%1
if not defined BATCH_ARG goto :eof
rem "&" or ">" or "<" is included
set "BATCH_ARG=!BATCH_ARG:^^=^!"
:arg_exists
set "BATCH_ARG=!BATCH_ARG:\"=\\"!"
set "BATCH_ARG=!BATCH_ARG:"=\"!"
set "BATCH_ARG=!BATCH_ARG:'=''!"
set "PWSH_ARGS=!PWSH_ARGS! "'!BATCH_ARG!'""
shift & goto :quote_args
: #>
以下にPowerShellスクリプトを書く
7行目を下記に置き換えると、\$PSCommandPath や \$PSScriptRoot を scriptスコープで参照できるようになります(localスコープは空)6。
Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|&([ScriptBlock]::Create((gc '%~f0'|Out-String)))" !PWSH_ARGS!
クリエイティブ・コモンズ 表示 - 継承 4.0 国際
-
5行目を
endlocal & Powershell -NoP -C "$input|.([ScriptBlock]::Create('$PSCommandPath=''%~f0'';$PSScriptRoot=''%~dp0''.TrimEnd(''\'');'+(gc '%~f0'|Out-String)))" %PWSH_ARGS%
とすると localスコープでも\$PSScriptRoot、\$PSCommandPathを参照可能(但しGet-Variableによる参照はなぜか空)になりますが、スクリプトにCmdletBinding属性やParameter属性を宣言できなくなる副作用があります。また、endlocal & Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|.([ScriptBlock]::Create((gc '%~f0'|Out-String)))" %PWSH_ARGS%
とすると、変数 PSScriptRoot、PSCommandPath の内容を Get-Variable で参照できる(CmdletBinding等の宣言もOK)のに、今度は\$PSScriptRoot、\$PSCommandPath での参照が空になってしまいます。不思議! ↩ -
@Powershell -NoP -C "$input|.([ScriptBlock]::Create('$MyInvocation.MyCommand|Add-Member -M NoteProperty -Na Path -V ''%~f0'';$PSCommandPath=''%~f0'';$PSScriptRoot=''%~dp0''.TrimEnd(''\'');'+(gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)))" %* & pause & exit/b
とすると localスコープで \$PSCommandPath や \$PSScriptRootが参照可能(Get-Variableによる参照もOK)になるほか \$MyInvocation.MyCommand.Path も参照できるようになりますが、スクリプトにCmdletBinding属性やParameter属性を宣言できなくなる副作用があります。[2022.1.8, 2023.4.12追記] スクリプトにCmdletBinding属性やParameter属性があっても動作するよう試験的に対応してみました(1行ですがなんと700文字超)。空白を含む引数にも簡易的に対応しています。⇒@setlocal EnableDelayedExpansion&set a=%*&(if defined a set a=!a:"=\"!)&Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|.([ScriptBlock]::Create(((gc '%~f0'|?{$_.ReadCount -gt 1}|Out-String)-replace'(?is)(^^(?:(?(o)(?:(?^!<#|@[''\""])[^^\s#''\""()])+|(?^!))|\s+|#[^^\n]*\n|<#(?:[^^#]|#(?^!>))*#>|''(?:[^^''`]|`.)*''|\""(?:[^^\""`]|`.)*\""|@''(?:(?^!\n''@).)*\n''@|@\""(?:(?^!\n\""@).)*\n\""@|\[(?:Alias|CmdletBinding)[^^\]]*\]|\[OutputType\((?>(?:[^^()]+|(?<o>\()|(?<-o>\)))*)(?(o)(?^!))\)\]|(?<p>Param)|(?(p)(?:(?<o>\()|(?<-o>\))(?(o)|(?<-p>)))|(?^!))|(?<b>Begin)|(?(b)\{|(?^!)))*)(.*)$','$1;$MyInvocation.MyCommand|Add-Member -M NoteProperty -Na Path -V ''%~f0'';$PSCommandPath=''%~f0'';$PSScriptRoot=''%~dp0''.TrimEnd(''\'');$2'))) !a!"&pause&exit/b
スクリプトの先頭からParam
文の終わりまたはBegin
ブロックの開始までにマッチする正規表現として(?is)(^(?:(?(o)(?:(?!<#|@['"])[^\s#'"()])+|(?!))|\s+|#[^\n]*\n|<#(?:[^#]|#(?!>))*#>|'(?:[^'`]|`.)*'|"(?:[^"`]|`.)*"|@'(?:(?!\n'@).)*\n'@|@"(?:(?!\n"@).)*\n"@|\[(?:Alias|CmdletBinding)[^\]]*\]|\[OutputType\((?>(?:[^()]+|(?<o>\()|(?<-o>\)))*)(?(o)(?!))\)\]|(?<p>Param)|(?(p)(?:(?<o>\()|(?<-o>\))(?(o)|(?<-p>)))|(?!))|(?<b>Begin)|(?(b)\{|(?!)))*)
を変数定義を挿入する位置を特定するために使っています。 ↩ -
PowerShellバージョン 5.1で確認しています。他のバージョンでは正常動作しない可能性があります。エスケープされていない
&
;
^
<
=
>
|
は Windowsバッチのコマンドラインパーサーで処理されてしまいますのでそのままでは引数として指定できません。また、バックスラッシュの直後に二重引用符が続く場合にバックスラッシュ(\
)の数が半分になるPowerShellのコマンドラインパーサーの変な仕様には対応できていません(とりあえず、二重引用符の直前にバックスラッシュが1個ある場合に正常に動作するようにしてあります)。 ↩ -
11行目の
%%1
を%%~1
に変更し、16行目のset "BATCH_ARG=!BATCH_ARG:^^=^!"
を:arg_exists
の行の下に移動することで二重引用符を外すことができますが、二重引用符に囲まれたキャレット^
が存在する場合の挙動がバッチファイルの場合と一致しなくなります(キャレットの数が減らない)。 ↩ -
7行目を
Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|.([ScriptBlock]::Create('$MyInvocation.MyCommand|Add-Member -M NoteProperty -Na Path -V ''%~f0'';$PSCommandPath=''%~f0'';$PSScriptRoot=''%~dp0''.TrimEnd(''\'');'+(gc '%~f0'|Out-String)))" !PWSH_ARGS!
とすると localスコープで \$PSCommandPath や \$PSScriptRootが参照可能(Get-Variableによる参照もOK)になるほか \$MyInvocation.MyCommand.Path も参照できるようになりますが、スクリプトにCmdletBinding属性やParameter属性を宣言できなくなる副作用があります。[2022.1.8, 2023.4.12追記] スクリプトにCmdletBinding属性やParameter属性があっても動作するよう試験的に対応してみました。⇒Powershell -NoP -C "$PSCommandPath='%~f0';$PSScriptRoot='%~dp0'.TrimEnd('\');$input|.([ScriptBlock]::Create(((gc '%~f0'|Out-String)-replace'(?is)(^^(?:(?(o)(?:(?^!<#|@[''\""])[^^\s#''\""()])+|(?^!))|\s+|#[^^\n]*\n|<#(?:[^^#]|#(?^!>))*#\>|''(?:[^^''`]|`.)*''|\""(?:[^^\""`]|`.)*\""|@''(?:(?^!\n''@).)*\n''@|@\""(?:(?^!\n\""@).)*\n\""@|\[(?:Alias|CmdletBinding)[^^\]]*\]|\[OutputType\((?>(?:[^^()]+|(?<o>\()|(?<-o>\)))*)(?(o)(?^!))\)\]|(?<p>Param)|(?(p)(?:(?<o>\()|(?<-o>\))(?(o)|(?<-p>)))|(?^!))|(?<b>Begin)|(?(b)\{|(?^!)))*)(.*)$','$1;$MyInvocation.MyCommand|Add-Member -M NoteProperty -Na Path -V ''%~f0'';$PSCommandPath=''%~f0'';$PSScriptRoot=''%~dp0''.TrimEnd(''\'');$2')))" !PWSH_ARGS!
↩