feat: preflight as a release asset - download, verify, unpack, then run the tree preflight (#151, C)
Files changed: - .gitea/workflows/release.yml - CHANGES.md - INSTALL.md - README.md - VERSION - instructions/dev/testing-conventions.md - instructions/preflight.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/preflight.ps1 - tools/preflight.sh
This commit is contained in:
1 parent
210e0c8286
commit
c33e8cdfb1
13 files changed
+1020
-88
No files matched your search
+239
-49
@@ -5,6 +5,17 @@
|
||||
# pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
# pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 --set rg=C:\Tools\rg.exe
|
||||
#
|
||||
# As the release asset `preflight.ps1` - a copy with no tools/prerequisites.txt next
|
||||
# to it - the script is the first install step instead: it downloads the stack
|
||||
# release named in $ReleaseArchiveUrl / $ReleaseChecksumUrl, checks the sha256,
|
||||
# unpacks it into <script folder>\chemenu (or --into <path>), and runs the copy of
|
||||
# this script inside the unpacked tree, which does everything above.
|
||||
#
|
||||
# pwsh -NoProfile -ExecutionPolicy Bypass -File preflight.ps1 [--into <path>] [--archive <tarball>]
|
||||
#
|
||||
# --archive uses a local tarball instead of a download; <tarball>.sha256 has to lie
|
||||
# next to it. Release builds fill the two URL lines below; a tree copy leaves them empty.
|
||||
#
|
||||
# Always start it that way (instructions/preflight.md): the bypass holds for this one
|
||||
# process only and changes no setting, and it is what lets a script that carries a
|
||||
# Mark of the Web start at all, so that it can report its own mark.
|
||||
@@ -13,7 +24,8 @@
|
||||
# Exit 42: the user has to act. The output says what, why, the command that fixes
|
||||
# it and what happens next; show it verbatim and wait (AGENTS.md, Tool
|
||||
# error contract). Never install anything on the user's behalf.
|
||||
# Exit 1: this script was called wrongly, or the tree next to it is incomplete.
|
||||
# Exit 1: this script was called wrongly, a download or unpack failed (nothing is
|
||||
# unpacked then), or the tree next to it is incomplete.
|
||||
#
|
||||
# The counterpart of tools/preflight.sh, and the two answer the same questions from the
|
||||
# same list, tools/prerequisites.txt. What only this one checks: the PowerShell execution
|
||||
@@ -40,6 +52,10 @@ $Requirements = Join-Path $Dir 'requirements.txt'
|
||||
$Stamp = Join-Path $Venv '.chemenu-requirements.sha256'
|
||||
$Self = 'pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1'
|
||||
|
||||
# Filled in by the release build, in the copy attached to the release; empty in a tree.
|
||||
$ReleaseArchiveUrl = ''
|
||||
$ReleaseChecksumUrl = ''
|
||||
|
||||
$PyProbe = "import sys; print(str(sys.version_info[0]) + '.' + str(sys.version_info[1])); print(sys.executable)"
|
||||
|
||||
function Write-Line {
|
||||
@@ -55,6 +71,8 @@ function Write-ErrorLine {
|
||||
# --- arguments ----------------------------------------------------------------
|
||||
|
||||
$Sets = @{}
|
||||
$Into = ''
|
||||
$Archive = ''
|
||||
$index = 0
|
||||
while ($index -lt $args.Count) {
|
||||
$arg = [string]$args[$index]
|
||||
@@ -68,12 +86,30 @@ while ($index -lt $args.Count) {
|
||||
$given = [string]$args[$index]
|
||||
} elseif ($arg.StartsWith('--set=')) {
|
||||
$given = $arg.Substring(6)
|
||||
} elseif ($arg -eq '--into' -or $arg -eq '--archive') {
|
||||
if ($index + 1 -ge $args.Count -or -not [string]$args[$index + 1]) {
|
||||
Write-ErrorLine "preflight: $arg needs a path"
|
||||
exit 1
|
||||
}
|
||||
$index++
|
||||
if ($arg -eq '--into') {
|
||||
$Into = [string]$args[$index]
|
||||
} else {
|
||||
$Archive = [string]$args[$index]
|
||||
}
|
||||
$index++
|
||||
continue
|
||||
} elseif ($arg -eq '-h' -or $arg -eq '--help') {
|
||||
Write-Line "usage: $Self [--set <tool>=<path>]..."
|
||||
Write-Line ' preflight.ps1 [--into <path>] [--archive <tarball>] [--set <tool>=<path>]...'
|
||||
Write-Line ''
|
||||
Write-Line 'Checks the tools this stack needs (tools/prerequisites.txt), records their paths'
|
||||
Write-Line 'in .wikitool-tools.json and sets up tools/.venv. Exit 0 means ready; exit 42'
|
||||
Write-Line 'means the user has to act - the output says how.'
|
||||
Write-Line ''
|
||||
Write-Line 'The second form is the release asset: it downloads the release (or takes'
|
||||
Write-Line '--archive), checks its sha256, unpacks it into <script folder>\chemenu or --into,'
|
||||
Write-Line 'and runs the preflight inside it.'
|
||||
exit 0
|
||||
} else {
|
||||
Write-ErrorLine "preflight: unknown argument: $arg"
|
||||
@@ -88,8 +124,9 @@ while ($index -lt $args.Count) {
|
||||
$index++
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Manifest -PathType Leaf)) {
|
||||
Write-ErrorLine "preflight: $Manifest is missing - this script has to run from inside an unpacked stack tree."
|
||||
$AssetMode = -not (Test-Path -LiteralPath $Manifest -PathType Leaf)
|
||||
if (-not $AssetMode -and ($Into -or $Archive)) {
|
||||
Write-ErrorLine 'preflight: --into and --archive belong to the release asset; inside a stack tree there is nothing to unpack.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -132,6 +169,205 @@ function Exit-WithGuide {
|
||||
exit 42
|
||||
}
|
||||
|
||||
# Runs a program; its standard output joined by newlines, or $null when it did not
|
||||
# start or did not exit 0.
|
||||
function Invoke-Native {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Same, but with the error stream merged in, for the messages the user is shown.
|
||||
function Invoke-NativeVerbose {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>&1
|
||||
return [pscustomobject]@{
|
||||
Code = $LASTEXITCODE
|
||||
Output = (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
}
|
||||
} catch {
|
||||
return [pscustomobject]@{ Code = -1; Output = $_.Exception.Message }
|
||||
}
|
||||
}
|
||||
|
||||
# --- asset mode: the release asset unpacks the stack, then hands over ------------------
|
||||
#
|
||||
# Only when no tools/prerequisites.txt lies next to this script. Nothing here reads
|
||||
# the tree; the folder limit comes out of the archive itself. The tools the tree copy
|
||||
# checks (Python, git, rg) are not needed until that copy runs.
|
||||
|
||||
function Test-LongPathsEnabled {
|
||||
if ($env:CHEMENU_PREFLIGHT_LONGPATHS) {
|
||||
return $env:CHEMENU_PREFLIGHT_LONGPATHS -eq '1'
|
||||
}
|
||||
# An unreadable key counts as "off": the limit then protects a machine it did not
|
||||
# have to.
|
||||
if (-not $IsWindows) {
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
$value = Get-ItemPropertyValue -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled
|
||||
return $value -eq 1
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-Asset {
|
||||
param([string]$Message)
|
||||
Write-ErrorLine "preflight: $Message"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Resolve-AssetPath {
|
||||
param([string]$Path)
|
||||
if (-not [IO.Path]::IsPathRooted($Path)) {
|
||||
$Path = Join-Path (Get-Location).ProviderPath $Path
|
||||
}
|
||||
return [IO.Path]::GetFullPath($Path).TrimEnd('\', '/')
|
||||
}
|
||||
|
||||
function Invoke-AssetMode {
|
||||
$target = if ($Into) { Resolve-AssetPath $Into } else { Join-Path $Dir 'chemenu' }
|
||||
if ($null -ne (Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue)) {
|
||||
Stop-Asset "$target already exists - nothing was unpacked. Choose another folder with --into <path>, or move the existing one away."
|
||||
}
|
||||
|
||||
if (-not $Archive -and (-not $ReleaseArchiveUrl -or -not $ReleaseChecksumUrl)) {
|
||||
Stop-Asset 'this copy of the script does not come from a release (it has no download address). Download preflight.ps1 from the release page, or pass --archive <tarball>.'
|
||||
}
|
||||
if ($Archive) {
|
||||
$Archive = Resolve-AssetPath $Archive
|
||||
if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) {
|
||||
Stop-Asset "--archive: $Archive is not a file."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath "$Archive.sha256" -PathType Leaf)) {
|
||||
Stop-Asset "--archive: $Archive.sha256 is missing - the checksum file has to lie next to the tarball."
|
||||
}
|
||||
}
|
||||
|
||||
$tar = Get-Command tar -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $tar) {
|
||||
Add-Problem 'The tool needed to unpack the stack (tar) could not be found.' `
|
||||
'the first step downloads the release, checks its checksum and unpacks it' `
|
||||
'Windows 10 and 11 ship tar.exe in C:\Windows\System32 - if it is missing, repair or update Windows.'
|
||||
Exit-WithGuide
|
||||
}
|
||||
|
||||
$work = Join-Path ([IO.Path]::GetTempPath()) "chemenu-preflight.$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
$unpack = ''
|
||||
try {
|
||||
$null = New-Item -ItemType Directory -Path $work
|
||||
if ($Archive) {
|
||||
$archivePath = $Archive
|
||||
$checksumPath = "$Archive.sha256"
|
||||
} else {
|
||||
$archivePath = Join-Path $work ($ReleaseArchiveUrl.Split('/')[-1])
|
||||
$checksumPath = Join-Path $work ($ReleaseChecksumUrl.Split('/')[-1])
|
||||
Write-Line "Downloading $ReleaseArchiveUrl"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
foreach ($pair in @(@($ReleaseArchiveUrl, $archivePath), @($ReleaseChecksumUrl, $checksumPath))) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $pair[0] -OutFile $pair[1]
|
||||
} catch {
|
||||
Stop-Asset "the download failed: $($pair[0]) ($($_.Exception.Message)) - check the internet connection, then run this again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$first = Get-Content -LiteralPath $checksumPath -TotalCount 1 -Encoding utf8
|
||||
$match = [regex]::Match("$first", '^([0-9A-Fa-f]{64})')
|
||||
if (-not $match.Success) {
|
||||
Stop-Asset 'the checksum file holds no sha256 in its first line - nothing was unpacked.'
|
||||
}
|
||||
$want = $match.Groups[1].Value.ToLowerInvariant()
|
||||
$have = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($want -ne $have) {
|
||||
Stop-Asset "the sha256 of the archive does not match its checksum file (expected $want, got $have) - nothing was unpacked. Delete the download and run this again."
|
||||
}
|
||||
Write-Line 'sha256 OK'
|
||||
|
||||
$listing = Invoke-Native $tar.Source @('-tzf', $archivePath)
|
||||
if ($null -eq $listing) {
|
||||
Stop-Asset 'the archive could not be read - nothing was unpacked.'
|
||||
}
|
||||
$tops = @(
|
||||
$listing -split "`n" |
|
||||
ForEach-Object { ($_ -replace '\\', '/' -replace '^\./', '').Split('/')[0] } |
|
||||
Where-Object { $_ -and $_ -ne '.' } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
if ($tops.Count -ne 1) {
|
||||
Stop-Asset 'the archive does not hold exactly one top-level folder - nothing was unpacked.'
|
||||
}
|
||||
$top = $tops[0]
|
||||
|
||||
$manifestText = Invoke-Native $tar.Source @('-xOzf', $archivePath, "$top/tools/prerequisites.txt")
|
||||
if ($null -eq $manifestText) {
|
||||
Stop-Asset "the archive holds no $top/tools/prerequisites.txt - nothing was unpacked."
|
||||
}
|
||||
$limit = 0
|
||||
foreach ($line in ($manifestText -split "`n")) {
|
||||
if ($line -match '^limit\|install_dir_max\|(\d+)') {
|
||||
$limit = [int]$Matches[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($Platform -eq 'windows' -and $limit -gt 0 -and -not (Test-LongPathsEnabled)) {
|
||||
$length = $target.Length
|
||||
if ($length -gt $limit) {
|
||||
Add-Problem "The folder this wiki would be installed in is too long ($length characters, at most $limit): $target" `
|
||||
"Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" `
|
||||
"Run this again with a shorter folder, for example: --into C:\Chemenu`nAlternatively, someone with administrator rights can turn on long paths in Windows."
|
||||
Exit-WithGuide
|
||||
}
|
||||
}
|
||||
|
||||
$parent = Split-Path -Parent $target
|
||||
$null = New-Item -ItemType Directory -Path $parent -Force
|
||||
$unpack = Join-Path $parent ".chemenu-unpack.$PID"
|
||||
$null = New-Item -ItemType Directory -Path $unpack
|
||||
$null = Invoke-NativeVerbose $tar.Source @('-xzf', $archivePath, '-C', $unpack)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $unpack $top) -PathType Container)) {
|
||||
Stop-Asset "unpacking failed - the target was not created."
|
||||
}
|
||||
Move-Item -LiteralPath (Join-Path $unpack $top) -Destination $target
|
||||
} finally {
|
||||
foreach ($leftover in @($work, $unpack)) {
|
||||
if ($leftover -and (Test-Path -LiteralPath $leftover)) {
|
||||
Remove-Item -LiteralPath $leftover -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$treeScript = Join-Path $target 'tools/preflight.ps1'
|
||||
if (-not (Test-Path -LiteralPath $treeScript -PathType Leaf)) {
|
||||
Stop-Asset 'the unpacked stack has no tools/preflight.ps1.'
|
||||
}
|
||||
Write-Line "Unpacked into $target."
|
||||
Write-Line 'If a later step stops, run tools/preflight.ps1 from inside that folder.'
|
||||
Write-Line ''
|
||||
$passed = @()
|
||||
foreach ($name in $Sets.Keys) {
|
||||
$passed += "--set=$name=$($Sets[$name])"
|
||||
}
|
||||
& ([Environment]::ProcessPath) -NoProfile -ExecutionPolicy Bypass -File $treeScript @passed
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
if ($AssetMode) {
|
||||
Invoke-AssetMode
|
||||
}
|
||||
|
||||
# --- the manifest ---------------------------------------------------------------
|
||||
|
||||
$ManifestLines = @(
|
||||
@@ -299,35 +535,6 @@ function Find-OnPath {
|
||||
return $found
|
||||
}
|
||||
|
||||
# Runs a program; its standard output joined by newlines, or $null when it did not
|
||||
# start or did not exit 0.
|
||||
function Invoke-Native {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Same, but with the error stream merged in, for the messages the user is shown.
|
||||
function Invoke-NativeVerbose {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>&1
|
||||
return [pscustomobject]@{
|
||||
Code = $LASTEXITCODE
|
||||
Output = (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
}
|
||||
} catch {
|
||||
return [pscustomobject]@{ Code = -1; Output = $_.Exception.Message }
|
||||
}
|
||||
}
|
||||
|
||||
# The value recorded for <name> in .wikitool-tools.json.
|
||||
function Get-RecordedPath {
|
||||
param([string]$Name)
|
||||
@@ -511,23 +718,6 @@ foreach ($tool in $Tools) {
|
||||
|
||||
# --- install folder length (Windows, long paths off) ------------------------------
|
||||
|
||||
function Test-LongPathsEnabled {
|
||||
if ($env:CHEMENU_PREFLIGHT_LONGPATHS) {
|
||||
return $env:CHEMENU_PREFLIGHT_LONGPATHS -eq '1'
|
||||
}
|
||||
# An unreadable key counts as "off": the limit then protects a machine it did not
|
||||
# have to.
|
||||
if (-not $IsWindows) {
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
$value = Get-ItemPropertyValue -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled
|
||||
return $value -eq 1
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) {
|
||||
$limit = [int](Get-ManifestField 'limit' 'install_dir_max' 3)
|
||||
$length = $Root.Length
|
||||
|
||||
Reference in new issue
Block a user