# 解決透過 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 了。