Skip to content
101 changes: 101 additions & 0 deletions .github/workflows/hooks-test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Test Plugin

on:
workflow_dispatch:
pull_request:
push:
branches: [main]
Expand All @@ -21,3 +22,103 @@ jobs:
cache: false
- name: Test plugin hooks with vfox's Lua interpreter
run: go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/hooks_test.lua
- name: Test Windows installer command generation
run: go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/windows_install_test.lua

windows-install:
name: Windows installer paths
runs-on: windows-latest
timeout-minutes: 20
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install released vfox
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$directory = Join-Path $env:RUNNER_TEMP 'vfox-cli'
gh release download v1.0.12 --repo version-fox/vfox --pattern vfox_1.0.12_windows_x86_64.zip --dir $directory
Expand-Archive -LiteralPath (Join-Path $directory 'vfox_1.0.12_windows_x86_64.zip') -DestinationPath $directory
$vfox = Get-ChildItem -LiteralPath $directory -Recurse -Filter vfox.exe | Select-Object -First 1
if (-not $vfox) { throw 'vfox.exe was not found in the release archive' }
"VFOX_TEST_CLI=$($vfox.FullName)" >> $env:GITHUB_ENV
- name: Enable installer diagnostics on the disposable runner
run: |
$policy = 'HKLM:\Software\Policies\Microsoft\Windows\Installer'
New-Item -Path $policy -Force | Out-Null
New-ItemProperty -Path $policy -Name Logging -Value 'voicewarmupx' -PropertyType String -Force | Out-Null
- name: Install Python through WiX and embedded MSI packages
run: |
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
# Literal percent/exclamation marks also catch cmd expansion bugs.
# Keep plugin staging and installation on the user's volume.
$env:VFOX_HOME = Join-Path $env:USERPROFILE 'Mechael Jackson''s & %USERNAME% ! SDKs'
# vfox expects a custom shared root to exist before initialization.
[System.IO.Directory]::CreateDirectory($env:VFOX_HOME) | Out-Null
$env:VFOX_PYTHON_USE_UV_BUILD = '0'
$archive = Join-Path $env:RUNNER_TEMP 'python-plugin.zip'
Compress-Archive -Path metadata.lua,hooks,lib,bin -DestinationPath $archive
& $env:VFOX_TEST_CLI add --source $archive
$junctionsBefore = @(Get-ChildItem -LiteralPath ([IO.Path]::GetTempPath()) -Filter 'vfox-python-msi-*' | ForEach-Object FullName)
$install = Start-Process -FilePath $env:VFOX_TEST_CLI -ArgumentList @('install', 'python@3.14.5') -NoNewWindow -PassThru
if (-not $install.WaitForExit(300000)) {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match '^(vfox|msiexec|powershell|dark)\.exe$' } |
Select-Object ProcessId,ParentProcessId,Name,CommandLine | Format-List
foreach ($directory in @($env:TEMP, (Join-Path $env:WINDIR 'Temp'))) {
Get-ChildItem -LiteralPath $directory -Filter 'MSI*.log' -ErrorAction SilentlyContinue |
ForEach-Object {
Write-Output "Installer log: $($_.FullName)"
Get-Content -LiteralPath $_.FullName -Tail 100 |
Select-String -Pattern 'Command Line|Return value 3|Error|Action ended|Product:'
}
}
throw 'Python installation did not finish within five minutes'
}
if ($install.ExitCode -ne 0) { throw "Python installation failed: $($install.ExitCode)" }
$expandedHome = [Environment]::ExpandEnvironmentVariables($env:VFOX_HOME)
if (Test-Path -LiteralPath $expandedHome) {
throw "Installer wrote to the expanded path instead of the literal path: $expandedHome"
}
$leftoverJunctions = @(Get-ChildItem -LiteralPath ([IO.Path]::GetTempPath()) -Filter 'vfox-python-msi-*' |
Where-Object { $_.FullName -notin $junctionsBefore })
if ($leftoverJunctions.Count -ne 0) { throw 'Installer did not clean up its temporary junctions' }

$root = Join-Path $env:VFOX_HOME 'cache\python\v-3.14.5\python-3.14.5'
$python = Join-Path $root 'python.exe'
if (-not (Test-Path -LiteralPath $python)) { throw "Python missing: $python" }
& $python -c 'import sys; assert sys.version_info[:3] == (3, 14, 5), sys.version; print(sys.executable)'
& $python -m pip --version
foreach ($name in @('python3.exe', 'python314.exe', 'python3.14.exe')) {
& (Join-Path $root $name) -c 'import sys; assert sys.version_info[:3] == (3, 14, 5)'
}
$venv = Join-Path $env:RUNNER_TEMP 'venv with spaces & symbols'
& $python -m venv $venv
& (Join-Path $venv 'Scripts\python.exe') -m pip --version
- name: Show installer destinations after a failure
if: failure()
run: |
foreach ($directory in @($env:TEMP, (Join-Path $env:WINDIR 'Temp'))) {
Get-ChildItem -LiteralPath $directory -Filter 'MSI*.log' -ErrorAction SilentlyContinue |
ForEach-Object {
Write-Output "Installer log: $($_.FullName)"
Select-String -LiteralPath $_.FullName -Pattern 'Command Line|TARGETDIR|InstallDirectory|ROOTDRIVE|FileCopy.*python|FileCopy.*ensurepip|Return value 3|MainEngineThread is returning' |
ForEach-Object { Write-Output $_.Line }
}
}
- name: Preserve MSI logs after a failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: windows-installer-logs
path: |
C:\Users\runneradmin\AppData\Local\Temp\MSI*.log
C:\Windows\Temp\MSI*.log
if-no-files-found: warn
66 changes: 48 additions & 18 deletions lib/util.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
local http = require("http")
local html = require("html")
local json = require("json")
local windowsCommand = require("windows_command")

-- get mirror
local PYTHON_URL = "https://www.python.org/ftp/python/"
Expand Down Expand Up @@ -91,7 +92,8 @@ function windowsInstallMsi(path, url, version, filename)

-- Install msi
print("Installing python...")
local command = 'msiexec /quiet /a ' .. qInstallFile .. ' TargetDir=' .. qInstallPath
print("Installing MSI package: " .. filename)
local command = windowsCommand.msi(qInstallFile, qInstallPath)
local exitCode = os.execute(command)
os.remove(qInstallFile)
if exitCode ~= 0 then
Expand All @@ -104,7 +106,7 @@ function windowsInstallMsi(path, url, version, filename)
if file then
io.close(file)
print("Installing pip...")
local command = qInstallPath .. '\\python -E -s -m ensurepip -U --default-pip > NUL'
local command = windowsCommand.native(qInstallPath .. '\\python.exe', {'-E', '-s', '-m', 'ensurepip', '-U', '--default-pip'})
local exitCode = os.execute(command)
if exitCode ~= 0 then
error("Install pip failed. exit " .. exitCode)
Expand Down Expand Up @@ -134,7 +136,7 @@ function windowsInstallExe(path, url, version, filename)
-- Extract
print("Extracting installer...")
local wixBin = RUNTIME.pluginDirPath .. '\\bin\\WiX\\dark.exe'
local command = wixBin .. " -x " .. qInstallPath .. '\\ ' .. qInstallFile .. ' > NUL'
local command = windowsCommand.native(wixBin, {'-x', qInstallPath, qInstallFile})
local exitCode = os.execute(command)
if exitCode ~= 0 then
error("Extract failed")
Expand All @@ -150,16 +152,38 @@ function windowsInstallExe(path, url, version, filename)

-- Install msi
print("Installing python...")
local files = io.popen("dir /b " .. msiPath):lines()
for file in files do
if file:match("%.msi$") then
local command = "msiexec /quiet /a " .. msiPath .. '\\' .. file .. " TargetDir=" .. qInstallPath
local exitCode = os.execute(command)
if exitCode ~= 0 then
error("Install msi failed: " .. file)
local listing, listErr = io.popen(windowsCommand.files(msiPath))
if not listing then
error('Failed to list installer packages: ' .. tostring(listErr))
end
local files = {}
local readOk, readErr = pcall(function()
for file in listing:lines() do
if file:match("%.msi$") then
files[#files + 1] = file
end
os.remove(qInstallPath .. '\\' .. file)
end
end)
-- GopherLua returns the process's numeric exit status from pipe close.
-- Close even after a read error, and reject partial results before installing.
local closeOk, listExitCode = pcall(function() return listing:close() end)
if not readOk then
error('Failed to list installer packages: ' .. tostring(readErr))
end
if not closeOk or listExitCode ~= 0 then
error('Failed to list installer packages: ' .. tostring(listExitCode))
end
if #files == 0 then
error('No installer MSI packages found in: ' .. msiPath)
end
for _, file in ipairs(files) do
print("Installing MSI package: " .. file)
local command = windowsCommand.msi(msiPath .. '\\' .. file, qInstallPath)
local exitCode = os.execute(command)
if exitCode ~= 0 then
error("Install msi failed: " .. file)
end
os.remove(qInstallPath .. '\\' .. file)
end

-- Install pip
Expand All @@ -168,7 +192,7 @@ function windowsInstallExe(path, url, version, filename)
local file = io.open(ensurepipPath, "r")
if file then
io.close(file)
local command = qInstallPath .. '\\python -E -s -m ensurepip -U --default-pip > NUL'
local command = windowsCommand.native(qInstallPath .. '\\python.exe', {'-E', '-s', '-m', 'ensurepip', '-U', '--default-pip'})
local exitCode = os.execute(command)
if exitCode ~= 0 then
error("Install pip failed. exit " .. exitCode)
Expand All @@ -190,16 +214,20 @@ function windowsInstallExe(path, url, version, filename)
local files = {qInstallPath .. "\\python" .. major .. ".exe", qInstallPath .. "\\python" .. majorMinor .. ".exe",
qInstallPath .. "\\python" .. majorDotMinor .. ".exe"}
for _, file in ipairs(files) do
local command = 'copy /y ' .. pythonExePath .. ' ' .. file .. ' > NUL'
os.execute(command)
local command = windowsCommand.copy(pythonExePath, file)
if os.execute(command) ~= 0 then
error('Failed to create Python executable alias: ' .. file)
end
end

-- pythonw.exe
local files = {qInstallPath .. "\\pythonw" .. major .. ".exe", qInstallPath .. "\\pythonw" .. majorMinor .. ".exe",
qInstallPath .. "\\pythonw" .. majorDotMinor .. ".exe"}
for _, file in ipairs(files) do
local command = 'copy /y ' .. pythonwExePath .. ' ' .. file .. ' > NUL'
os.execute(command)
local command = windowsCommand.copy(pythonwExePath, file)
if os.execute(command) ~= 0 then
error('Failed to create Python executable alias: ' .. file)
end
end

-- Check if venvlauncher exists
Expand All @@ -214,8 +242,10 @@ function windowsInstallExe(path, url, version, filename)
qInstallPath .. "\\Lib\\venv\\scripts\\nt\\pythonw" .. majorMinor .. ".exe",
qInstallPath .. "\\Lib\\venv\\scripts\\nt\\pythonw" .. majorDotMinor .. ".exe"}
for _, file in ipairs(files) do
local command = 'copy /y ' .. venvlauncherExePath .. ' ' .. file .. ' > NUL'
os.execute(command)
local command = windowsCommand.copy(venvlauncherExePath, file)
if os.execute(command) ~= 0 then
error('Failed to create Python venv executable alias: ' .. file)
end
end
end

Expand Down
99 changes: 99 additions & 0 deletions lib/windows_command.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
local command = {}

local alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
local function base64(value)
local result = {}
for i = 1, #value, 3 do
local a, b, c = value:byte(i, i + 2)
local number = a * 65536 + (b or 0) * 256 + (c or 0)
local first = math.floor(number / 262144) + 1
local second = math.floor(number / 4096) % 64 + 1
local third = math.floor(number / 64) % 64 + 1
local fourth = number % 64 + 1
result[#result + 1] = alphabet:sub(first, first) .. alphabet:sub(second, second) ..
(b and alphabet:sub(third, third) or '=') ..
(c and alphabet:sub(fourth, fourth) or '=')
end
return table.concat(result)
end

-- Encode UTF-8 values separately so even non-ASCII paths produce an ASCII
-- PowerShell script, without exposing arguments to either shell's parser.
local function literal(value)
if value:find('[\r\n%z]') then
error('Windows command argument contains a control character')
end
return "([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" .. base64(value) .. "')))"
end

local function powershell(script)
script = "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; " .. script
-- GopherLua launches cmd.exe /c, which reinterprets a quoted -Command.
-- -EncodedCommand takes UTF-16LE base64 with no quotes or metacharacters.
local utf16 = script:gsub('.', function(char)
assert(char:byte() < 128, 'PowerShell script must contain only ASCII')
return char .. '\0'
end)
return 'powershell -NoProfile -NonInteractive -EncodedCommand ' .. base64(utf16)
end

function command.native(program, args)
local parts = {'&', literal(program)}
for _, arg in ipairs(args) do
table.insert(parts, literal(arg))
end
return powershell(table.concat(parts, ' ') .. '; exit $LASTEXITCODE')
end

function command.msi(file, path)
local function argument(value)
if value:find('"', 1, true) then
error('Windows installer path contains an invalid quote')
end
-- A backslash immediately before the closing native quote is escaped.
return '"' .. value:gsub('(\\+)$', '%1%1') .. '"'
end
local args = '/quiet /a ' .. argument(file) .. ' TargetDir=' .. argument(path)
if path:find('%', 1, true) then
-- Windows Installer expands percent variables during FileCopy even
-- when TARGETDIR contains the literal path. Install through a temporary
-- junction, keeping the real SDK directory in its final location.
local script = '$target = ' .. literal(path) .. [[;
$temp = [IO.Path]::GetTempPath();
if ($temp.Contains('%')) {
throw 'Python MSI installation requires a TEMP directory without percent signs';
}
if (-not [IO.Directory]::Exists($target)) {
throw 'Python MSI junction target directory does not exist';
}
$alias = [IO.Path]::Combine($temp, 'vfox-python-msi-' + [Guid]::NewGuid().ToString('N'));
$created = $false;
$exitCode = 1;
try {
New-Item -ItemType Junction -Path $alias -Target $target | Out-Null;
$created = $true;
$arguments = ]] .. literal('/quiet /a ' .. argument(file) .. ' TargetDir=') .. [[ + [char]34 + $alias + [char]34;
$process = Start-Process -FilePath msiexec.exe -Wait -PassThru -ArgumentList $arguments;
$exitCode = $process.ExitCode;
} finally {
if ($created) {
[IO.Directory]::Delete($alias);
}
}; exit $exitCode]]
return powershell(script)
end
return powershell("$process = Start-Process -FilePath msiexec.exe -Wait -PassThru -ArgumentList " ..
literal(args) .. '; exit $process.ExitCode')
end

function command.files(path)
return powershell('Get-ChildItem -LiteralPath ' .. literal(path) ..
" -Filter '*.msi' -File | ForEach-Object { $_.Name }")
end

function command.copy(source, target)
return powershell('Copy-Item -LiteralPath ' .. literal(source) ..
' -Destination ' .. literal(target) .. ' -Force')
end

return command
Loading