# 解決透過 scoop 安裝的 pyenv 無法正常運作的問題
這幾天因為測試改用 [scoop](https://scoop.sh/#/) 當成軟體管理工具, 發現透過 scoop 安裝的 pyenv 無法正常安裝 Python, 會出現以下的錯誤訊息:
```
:: [Error] :: error installing "core" component MSI.
```
根據[善心人士的研究](https://github.com/pyenv-win/pyenv-win/issues/449), 發現 msi 格式的安裝套件需要以 `msiexe` 執行, 但是 `msiexe` 考量跳轉到其它資料夾的安全性, 所以如果遇到指定的安裝路徑含有 junction point 時, 並不會取得真正的路徑, 安裝就會失敗。這主要是因為 scoop 為了讓軟體更新時不會動到執行軟體的路徑, 所以每個軟體都會有一個 current 的 junction point, 連結到實際版本的資料夾, pyenv 會把 Python 安裝到透過 current 連結的資料夾下, 因此遇到 msiexe 不會取得真正路徑的問題。
pyenv 的 Windows 版本實際上使用了 VBScript 撰寫安裝 Python 的工作, 因此這就引起了我的好奇心, 要怎樣才能在 VBScript 中取得 junction point 的實際路徑, 這樣就可以再執行 msiexe 的時候, 改用真實路徑, 就可以避免前面提到的問題了。
如果你只想知道如何讓 pyenv 正常運作, 可以直接跳轉到[本文最後](#%E8%AE%93-scoop-%E5%AE%89%E8%A3%9D%E7%9A%84-pyenv-%E6%AD%A3%E5%B8%B8%E9%81%8B%E4%BD%9C)。
## 取得 junction point 實際路徑的指令
VBScript 時實際上沒辦法取得 junction point 的真實路徑, 因此就要看看是不是有現成的指令可用, 再由 VBScript 去執行指令後取得輸出結果。
要在 Windows 上取得 junction point 的真實路徑, 有以下三種指令:
- 使用內建工具 [`fsutil`](https://learn.microsoft.com/zh-tw/windows-server/administration/windows-commands/fsutil):
```
# fsutil reparsepoint query "current"
重新分析標記值 : 0xa0000003
標記值: Microsoft
標記值: Name Surrogate
標記值: Mount Point
取代名稱位移: 0
取代名稱長度: 80
列印名稱位移: 82
列印名稱長度: 0
取代名稱: \??\C:\Users\meebo\scoop\apps\7zip\23.01
重新分析資料長度:0x5c
重新分析資料:
0000: 00 00 50 00 52 00 00 00 5c 00 3f 00 3f 00 5c 00 ..P.R...\.?.?.\.
0010: 43 00 3a 00 5c 00 55 00 73 00 65 00 72 00 73 00 C.:.\.U.s.e.r.s.
0020: 5c 00 6d 00 65 00 65 00 62 00 6f 00 5c 00 73 00 \.m.e.e.b.o.\.s.
0030: 63 00 6f 00 6f 00 70 00 5c 00 61 00 70 00 70 00 c.o.o.p.\.a.p.p.
0040: 73 00 5c 00 37 00 7a 00 69 00 70 00 5c 00 32 00 s.\.7.z.i.p.\.2.
0050: 33 00 2e 00 30 00 31 00 00 00 00 00 3...0.1.....
```
『取代名稱』後面就是 current 這個 junction point 實際的路徑, 不過這個指令會依據系統選用的語系顯示對應語言的訊息, 像是剛剛看到的『取代名稱』在英文語系下就會是 "Substitute Name", 如果試想要透過程式從中取得實際路徑, 就要小心語系的問題。
- 使用需要額外下載的外部工具 [`junction`](https://learn.microsoft.com/en-us/sysinternals/downloads/junction):
```
# junction current
Junction v1.07 - Creates and lists directory links
Copyright (C) 2005-2016 Mark Russinovich
Sysinternals - www.sysinternals.com
C:\Users\meebo\scoop\apps\7zip\current: JUNCTION
Substitute Name: C:\Users\meebo\scoop\apps\7zip\23.01
```
它固定使用英文訊息, 所以不會有語系的問題。不過這個工具程式並沒有考慮到中文, 所以如果連結到中文資料夾的 junction point, 就會出現亂碼:
```
# junction test
Junction v1.07 - Creates and lists directory links
Copyright (C) 2005-2016 Mark Russinovich
Sysinternals - www.sysinternals.com
C:\Users\meebo\code\test\test: JUNCTION
Substitute Name: C:\Users\meebo\code\test\??
```
- 使用 PowerShell 的 [`get-item`](https://ss64.com/ps/get-item.html) 指令
這應該是最理想的方案:
```
# (get-item "current").Target
C:\Users\meebo\scoop\apps\7zip\23.01
```
即使是中文資料夾也沒問題:
```
# (get-item "test").Target
C:\Users\meebo\code\test\測試
```
## 使用 VBScript 取得 junction point 的實際路徑
VBScript 其實沒有真正取得 junction point 的方法, 所以必須執行剛剛介紹的工具間接取得:
```vbs
foldername = "current"
Set sh = CreateObject("WScript.Shell")
Set fsutil = sh.Exec("fsutil reparsepoint query """ & foldername & """")
Do While fsutil.Status = 0
WScript.Sleep 100
Loop
If fsutil.ExitCode <> 0 Then
WScript.Echo "An error occurred (" & fsutil.ExitCode & ")."
WScript.Quit fsutil.ExitCode
End If
Set re = New RegExp
re.Pattern = "取代名稱:\s+(.*)"
For Each m In re.Execute(fsutil.StdOut.ReadAll)
targetPath = m.SubMatches(0)
Next
WScript.Echo targetPath
```
要記得 VBScript 是 Big5 編碼, 存檔時要注意。執行結果如下:
```
# cscript //U //nologo c:\users\meebo\code\test\test2.vbs
\??\C:\Users\meebo\scoop\apps\7zip\23.01
```
請留意實際的路徑之前還會有垃圾資料, 也要處理掉。
如同之前所說, 因為 fsutil 的輸出會因為語系而採用不同語言, 程式碼中以規則樣式找尋真實路徑位置的地方也要跟著改。如果想要避免這個麻煩, 也可以改用 junction 外部工具:
```vbs
foldername = "current"
Set sh = CreateObject("WScript.Shell")
Set fsutil = sh.Exec("junction """ & foldername & """")
Do While fsutil.Status = 0
WScript.Sleep 100
Loop
If fsutil.ExitCode <> 0 Then
WScript.Echo "An error occurred (" & fsutil.ExitCode & ")."
WScript.Quit fsutil.ExitCode
End If
Set re = New RegExp
re.Pattern = "Substitute Name:\s+(.*)"
For Each m In re.Execute(fsutil.StdOut.ReadAll)
targetPath = m.SubMatches(0)
Next
WScript.Echo targetPath
```
執行結果如下:
```
# cscript //U //nologo c:\users\meebo\code\test\test.vbs
C:\Users\meebo\scoop\apps\7zip\23.01
```
不過因為 fsutil 和 junction 各有缺點, 所以最理想的還是透過 PowerShell 指令:
```vbs
foldername = "test"
Set sh = CreateObject("WScript.Shell")
Set fsutil = sh.Exec("powershell -command (get-item """ & foldername & """).target")
Do While fsutil.Status = 0
WScript.Sleep 100
Loop
If fsutil.ExitCode <> 0 Then
WScript.Echo "An error occurred (" & fsutil.ExitCode & ")."
WScript.Quit fsutil.ExitCode
End If
targetPath = fsutil.StdOut.ReadAll
WScript.Echo targetPath
```
執行結果如下:
```
# cscript //U //nologo c:\users\meebo\code\test\test1.vbs
C:\Users\meebo\scoop\apps\7zip\23.01
```
即使是連結到中文資料夾也沒有問題:
```
# cscript //U //nologo c:\users\meebo\code\test\test1.vbs
C:\Users\meebo\code\test\測試
```
## 讓 scoop 安裝的 pyenv 正常運作
有了剛剛的測試, 就可以解決 scoop 安裝的 pyenv 無法運作的問題了, 我先在它的 [libexec/libs/pyenv-install-lib.vbs](https://github.com/codemee/pyenv-win/blob/636581f1f55ee4f0f25d2c627e24537113495b5e/pyenv-win/libexec/libs/pyenv-install-lib.vbs#L282) 中新增了一個可以取得真實路徑的函式 getRealPath:
```vb
'Function to get the real path of a junction point
Dim sh
Set sh = CreateObject("WScript.Shell")
Function getRealPath(path)
Dim junctionPoints, junctionPoint, junctionPointPos
Dim cutPath, realPath
Dim fsutil
junctionPoints = Array("current", "install_cache", "versions")
Do While true
For Each junctionPoint In junctionPoints
junctionPointPos = InStrRev(path, junctionPoint)
If junctionPointPos > 0 Then
Exit For
End If
Next
if junctionPointPos = 0 Then
getRealPath = path
Exit Function
End If
junctionPoints = filter(junctionPoints, junctionPoint, False)
cutPath = Left(path, junctionPointPos + Len(junctionPoint) - 1)
Set fsutil = sh.Exec("powershell -command (get-item """ & cutPath & """).target")
Do While fsutil.Status = 0
WScript.Sleep 100
Loop
If fsutil.ExitCode = 0 Then
realPath = Replace(fsutil.StdOut.ReadAll, vbCrlf, "")
if Len(realPath) > 0 Then
path = realPath & "\" & Mid(path, junctionPointPos + Len(junctionPoint) + 1)
End If
End If
Loop
End Function
```
除了前面替到的 curretn 外, install_cache 以及 versions 也是 scoop 會使用到的 junction point, 所以上述函式會一一轉換成真實的路徑後傳回。
接著, 就在負責安裝工做的 [libexec/pyenv-install.vbs](https://github.com/codemee/pyenv-win/blob/636581f1f55ee4f0f25d2c627e24537113495b5e/pyenv-win/libexec/pyenv-install.vbs#L129) 中, 先把相關路徑轉成實際路徑後才開始安裝工作:
```vb
file = getRealPath(file) ' Get the real path to the file.
installPath = getRealPath(installPath) ' Get the real path to the install path.
deepExtract = objws.Run("msiexec /quiet /a """& file &""" TargetDir="""& installPath & """", 0, True)
```
依據這個想法, 我也提交了一份 [pull request](https://github.com/pyenv-win/pyenv-win/pull/606) 給 pyenv-win 了。