Files
chemenu/tools/preflight.ps1
T
torbenandClaude Opus 5.5 5c9be38aa8
CI / verify (push) Successful in 6m14s
CI / pwsh (push) Successful in 2m23s
Release / release (push) Successful in 35s
fix(preflight): asset mode retries a move that fails, names the cause and rolls back (#197)
A part of the stack that will not move from the unpack folder into place is
retried for about 30 seconds; each retry removes what reached the target and
unpacks the entry afresh from the archive, so the entry ends up there once and
whole. If it still fails, both scripts take back everything they moved and exit 1
with the system's message and "nothing was installed". New test hook
CHEMENU_PREFLIGHT_MOVE_FAIL=<entry>:<n>[:partial].

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- instructions/preflight.md
- tools/CONTRACT.md
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/preflight.ps1
- tools/preflight.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
2026-10-07 12:23:13 +02:00

1028 lines
41 KiB
PowerShell

#Requires -Version 7
# Preflight: check that this machine has what the stack needs, record where each
# tool lives, and set up tools/.venv - before any `wikitool` command can run.
#
# 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 the folder it lies in (or --into <path>), removes itself there, and
# runs the copy of this script inside the unpacked tree, which does everything above.
# That folder has to be empty apart from this script and a .git (an empty clone of the
# instance's own repository).
#
# 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.
#
# Exit 0: everything is in place and .wikitool-tools.json is complete.
# 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, a download or unpack failed (nothing is
# unpacked then - or, should even that fail, the message names what is
# left), 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
# policy and a Mark of the Web on the stack's own scripts - the two things that stop
# tools/wikitool.ps1 from starting, and that exist only here. Windows PowerShell 5.1 is
# not supported; `#Requires` turns it away.
#
# Five variables exist for the test suite and are read nowhere else:
# CHEMENU_PREFLIGHT_PLATFORM (windows|macos|linux), CHEMENU_PREFLIGHT_LONGPATHS (0|1),
# CHEMENU_PREFLIGHT_POLICY (`Scope=Policy,...`, as `Get-ExecutionPolicy -List` names them)
# and CHEMENU_PREFLIGHT_MARKED (comma-separated script names below tools/) stand in for
# the operating system, the registry, the policy and the file streams.
# CHEMENU_PREFLIGHT_MOVE_FAIL (<entry>:<n>[:partial]) stands in for a file some other
# program holds open: moving the unpacked <entry> up fails the first <n> times (`always`:
# every time), with :partial after moving a part of it, and the pauses between attempts
# are skipped.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandArgumentPassing = 'Standard'
$Dir = $PSScriptRoot
$Root = Split-Path -Parent $Dir
$Manifest = Join-Path $Dir 'prerequisites.txt'
$ToolsFile = Join-Path $Root '.wikitool-tools.json'
$Venv = Join-Path $Dir '.venv'
$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 {
param([string]$Text = '')
[Console]::Out.WriteLine($Text)
}
function Write-ErrorLine {
param([string]$Text)
[Console]::Error.WriteLine($Text)
}
# --- arguments ----------------------------------------------------------------
$Sets = @{}
$Into = ''
$Archive = ''
$index = 0
while ($index -lt $args.Count) {
$arg = [string]$args[$index]
$given = $null
if ($arg -eq '--set') {
if ($index + 1 -ge $args.Count) {
Write-ErrorLine 'preflight: --set needs <tool>=<path>'
exit 1
}
$index++
$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 its own folder or --into (empty,'
Write-Line 'or holding only .git), and runs the preflight inside it.'
exit 0
} else {
Write-ErrorLine "preflight: unknown argument: $arg"
exit 1
}
$pivot = $given.IndexOf('=')
if ($pivot -lt 1) {
Write-ErrorLine "preflight: --set needs <tool>=<path>, got '$given'"
exit 1
}
$Sets[$given.Substring(0, $pivot)] = $given.Substring($pivot + 1)
$index++
}
$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
}
# --- platform -----------------------------------------------------------------
if ($env:CHEMENU_PREFLIGHT_PLATFORM) {
$Platform = $env:CHEMENU_PREFLIGHT_PLATFORM
} elseif ($IsWindows) {
$Platform = 'windows'
} elseif ($IsMacOS) {
$Platform = 'macos'
} else {
$Platform = 'linux'
}
# --- problems and the guidance block --------------------------------------------
$script:ProblemCount = 0
$script:Guide = ''
$script:BadSet = $false
function Add-Problem {
param([string]$What, [string]$Why, [string]$Fix)
$script:ProblemCount++
$fixText = ($Fix -split "`n") -join "`n "
$script:Guide += "`n$($script:ProblemCount)) $What`n Why: $Why`n Fix: $fixText`n Next: Tell the agent once this is done - it runs this check again.`n"
}
function Exit-WithGuide {
if ($script:ProblemCount -eq 1) {
$noun = '1 thing needs'
} else {
$noun = "$($script:ProblemCount) things need"
}
Write-Line ''
Write-Line "STOP - $noun your attention before this wiki can run."
Write-Line '(Agent: show this output to the user exactly as it is, then wait. Do not install'
Write-Line 'anything yourself and do not work around it.)'
[Console]::Out.Write($script:Guide)
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 Exit-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('\', '/')
}
# This script and a .git are the two things an empty install folder may already carry:
# the script was downloaded into it, and the user may have cloned the instance's own,
# still empty repository there. Neither counts as occupying the folder, and neither is
# ever removed from it.
function Test-ProtectedName {
param([string]$Target, [string]$Name)
if ($Name -eq '.git') {
return $true
}
$isOwnFolder = [IO.Path]::GetFullPath($Target).TrimEnd('\', '/') -eq [IO.Path]::GetFullPath($Dir).TrimEnd('\', '/')
return $isOwnFolder -and $Name -eq (Split-Path -Leaf $PSCommandPath)
}
# The names in the install folder besides those two.
function Get-TargetOccupant {
param([string]$Target)
return @(Get-ChildItem -LiteralPath $Target -Force |
Where-Object { -not (Test-ProtectedName $Target $_.Name) } |
ForEach-Object { $_.Name })
}
# How long an entry that will not move up is retried: the pauses between attempts, in
# seconds - 30 in all. On Windows a folder cannot be renamed while any file in it is
# held open, and a virus scanner or indexer looking at a freshly unpacked tree holds one
# for a moment; waiting is what gets past it.
$MovePauses = @(1, 2, 3, 4, 5, 6, 9)
# One attempt at moving the unpacked entry <Name> up into the target; the system's
# message when it fails, $null when it worked. A failed attempt can leave part of the
# entry on either side - a move that falls back to copying and deleting stops wherever
# the lock is - so every attempt after the first starts over: what reached the target is
# removed, and the entry is unpacked afresh from the archive. Only then is the entry in
# the target, once and whole, after the attempt that works.
function Move-AssetEntry {
param([string]$Name, [int]$Attempt, [string]$Target, [string]$Unpack, [string]$Top, [string]$Tar, [string]$ArchivePath)
$source = Join-Path (Join-Path $Unpack $Top) $Name
$landed = Join-Path $Target $Name
try {
if ($Attempt -gt 1) {
if ((Test-Path -LiteralPath $landed) -and -not (Test-ProtectedName $Target $Name)) {
Remove-Item -LiteralPath $landed -Recurse -Force
}
$again = Invoke-NativeVerbose $Tar @('-xzf', $ArchivePath, '-C', $Unpack, "$Top/$Name")
if ($again.Code -ne 0) {
return "unpacking it again failed: $($again.Output)"
}
}
$hook = "$env:CHEMENU_PREFLIGHT_MOVE_FAIL".Split(':')
if ($hook[0] -eq $Name -and $hook.Count -gt 1 -and ($hook[1] -eq 'always' -or $Attempt -le [int]$hook[1])) {
if ($hook.Count -gt 2 -and $hook[2] -eq 'partial') {
if (Test-Path -LiteralPath $source -PathType Container) {
$null = New-Item -ItemType Directory -Path $landed -Force
$part = Get-ChildItem -LiteralPath $source -Force | Select-Object -First 1
if ($part) {
Move-Item -LiteralPath $part.FullName -Destination $landed
}
} else {
$null = New-Item -ItemType File -Path $landed -Force
}
}
return "Access to the path '$source' is denied. (CHEMENU_PREFLIGHT_MOVE_FAIL, attempt $Attempt)"
}
Move-Item -LiteralPath $source -Destination $Target
return $null
} catch {
return $_.Exception.Message
}
}
# An entry would not move even after waiting: take back everything this run moved into
# the target, so that it holds what it held before and running this again is all it
# takes. Only names that came out of the archive are removed, never a protected one.
function Undo-AssetMove {
param([string]$Target, [string]$Unpack, [string[]]$Moved, [string]$Name, [string]$Message)
foreach ($entry in $Moved + $Name) {
$path = Join-Path $Target $entry
if (-not (Test-ProtectedName $Target $entry) -and (Test-Path -LiteralPath $path)) {
try {
Remove-Item -LiteralPath $path -Recurse -Force
} catch {
$null = $_
}
}
}
try {
Remove-Item -LiteralPath $Unpack -Recurse -Force
} catch {
$null = $_
}
$left = @(Get-TargetOccupant $Target)
if ($left.Count -eq 0) {
Exit-Asset "could not move $Name into $Target ($Message) - nothing was installed, the folder is as it was before. Run this again."
}
Exit-Asset "could not move $Name into $Target ($Message), and could not take back what was already there: $($left -join ', ') - empty the folder (keep .git), then run this again."
}
function Invoke-AssetMode {
$target = if ($Into) { Resolve-AssetPath $Into } else { $Dir.TrimEnd('\', '/') }
$existing = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
if ($null -ne $existing -and -not $existing.PSIsContainer) {
Exit-Asset "$target exists and is not a folder - nothing was unpacked."
}
if ($null -ne $existing -and @(Get-TargetOccupant $target).Count -gt 0) {
Exit-Asset "$target is not empty - nothing was unpacked. The wiki installs into an empty folder (an empty git clone is fine): put this script into one and run it there, or pass --into <path>."
}
if (-not $Archive -and (-not $ReleaseArchiveUrl -or -not $ReleaseChecksumUrl)) {
Exit-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)) {
Exit-Asset "--archive: $Archive is not a file."
}
if (-not (Test-Path -LiteralPath "$Archive.sha256" -PathType Leaf)) {
Exit-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 {
Exit-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) {
Exit-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) {
Exit-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) {
Exit-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) {
Exit-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) {
Exit-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" `
"Use a shorter folder such as C:\Chemenu - put this script there and run it again,`nor pass --into C:\Chemenu.`nAlternatively, someone with administrator rights can turn on long paths in Windows."
Exit-WithGuide
}
}
# Unpacked inside the target rather than beside it: the folder above may be a drive
# root nobody can write to. Only then are the entries moved up, one by one.
$null = New-Item -ItemType Directory -Path $target -Force
$unpack = Join-Path $target ".chemenu-unpack.$PID"
$null = New-Item -ItemType Directory -Path $unpack
$null = Invoke-NativeVerbose $tar.Source @('-xzf', $archivePath, '-C', $unpack)
$unpacked = Join-Path $unpack $top
if (-not (Test-Path -LiteralPath $unpacked -PathType Container)) {
Exit-Asset 'unpacking failed - nothing was installed.'
}
$moved = @()
foreach ($entry in @(Get-ChildItem -LiteralPath $unpacked -Force)) {
$attempt = 1
while ($true) {
$failure = Move-AssetEntry $entry.Name $attempt $target $unpack $top $tar.Source $archivePath
if ($null -eq $failure) {
break
}
if ($attempt -gt $MovePauses.Count) {
Undo-AssetMove $target $unpack $moved $entry.Name $failure
}
if (-not $env:CHEMENU_PREFLIGHT_MOVE_FAIL) {
Start-Sleep -Seconds $MovePauses[$attempt - 1]
}
$attempt++
}
$moved += $entry.Name
}
} 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)) {
Exit-Asset 'the unpacked stack has no tools/preflight.ps1.'
}
# The release asset is not part of the stack; left in the folder, it would end up in
# the instance's first commit beside the tree's own tools/preflight.ps1. PowerShell has
# read the whole script before running it, so removing the file is safe here.
if ($target -eq $Dir.TrimEnd('\', '/')) {
Remove-Item -LiteralPath $PSCommandPath -Force
}
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 = @(
Get-Content -LiteralPath $Manifest -Encoding utf8 |
ForEach-Object { $_.TrimEnd("`r") } |
Where-Object { $_.Trim() -ne '' -and -not $_.StartsWith('#') }
)
function Get-ManifestField {
param([string]$Kind, [string]$Name, [int]$Field)
foreach ($line in $ManifestLines) {
$parts = $line.Split('|')
if ($parts.Count -gt 1 -and $parts[0] -eq $Kind -and $parts[1] -eq $Name) {
if ($Field -le $parts.Count) {
return $parts[$Field - 1]
}
return ''
}
}
return ''
}
$Tools = @()
foreach ($line in $ManifestLines) {
$parts = $line.Split('|')
if ($parts[0] -ne 'tool') {
continue
}
if ($parts[1] -notmatch '^[a-z0-9]+$') {
Write-ErrorLine "preflight: bad tool name '$($parts[1])' in $Manifest"
exit 1
}
if ($parts[3] -eq 'all' -or $parts[3] -eq $Platform) {
$Tools += $parts[1]
}
}
function Get-InstallHint {
param([string]$Tool)
$choco = Get-ManifestField 'tool' $Tool 7
$winget = Get-ManifestField 'tool' $Tool 8
$brew = Get-ManifestField 'tool' $Tool 9
$apt = Get-ManifestField 'tool' $Tool 10
$pacman = Get-ManifestField 'tool' $Tool 11
$hint = ''
switch ($Platform) {
'windows' {
if ((Get-Command choco -CommandType Application -ErrorAction SilentlyContinue) -and $choco -ne '-') {
$hint = "$choco (in a terminal opened as administrator)"
} elseif ((Get-Command winget -CommandType Application -ErrorAction SilentlyContinue) -and $winget -ne '-') {
$hint = $winget
}
}
'macos' {
if ((Get-Command brew -CommandType Application -ErrorAction SilentlyContinue) -and $brew -ne '-') {
$hint = $brew
}
}
default {
if ((Get-Command apt-get -CommandType Application -ErrorAction SilentlyContinue) -and $apt -ne '-') {
$hint = $apt
} elseif ((Get-Command pacman -CommandType Application -ErrorAction SilentlyContinue) -and $pacman -ne '-') {
$hint = $pacman
}
}
}
if ($hint) {
return $hint
}
$lines = @('Install it with the package manager this computer uses, for example:')
foreach ($entry in @($choco, $winget, $brew, $apt, $pacman)) {
if ($entry -and $entry -ne '-') {
$lines += " $entry"
}
}
return ($lines -join "`n")
}
# --- version helpers ------------------------------------------------------------
# major.minor of the first version-looking token in the text ("git version 2.47.1" -> 2.47)
function Get-VersionOf {
param([string]$Text)
$first = ($Text -split "`n" | Select-Object -First 1)
if ($null -eq $first) {
return ''
}
$match = [regex]::Match($first, '(\d+)(?:\.(\d+))?')
if (-not $match.Success) {
return ''
}
if ($match.Groups[2].Success) {
return "$($match.Groups[1].Value).$($match.Groups[2].Value)"
}
return $match.Groups[1].Value
}
function Test-VersionGe {
param([string]$Have, [string]$Want)
$haveParts = ("$Have.0" -split '\.')[0, 1] | ForEach-Object { [int]$_ }
$wantParts = ("$Want.0" -split '\.')[0, 1] | ForEach-Object { [int]$_ }
if ($haveParts[0] -ne $wantParts[0]) {
return $haveParts[0] -gt $wantParts[0]
}
return $haveParts[1] -ge $wantParts[1]
}
# --- finding tools --------------------------------------------------------------
# The Microsoft Store's app-execution aliases: a python.exe that opens the Store
# instead of running anything. Never executed, never recorded.
function Test-StoreAlias {
param([string]$Path)
return $Path -match '(?i)[\\/]windowsapps[\\/]'
}
function Test-Usable {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf) -or (Test-StoreAlias $Path)) {
return $false
}
if ($IsWindows) {
return $true
}
return (([int][IO.File]::GetUnixFileMode($Path)) -band 73) -ne 0
}
# PATH as this process has it, plus - on Windows - whatever the registry holds that a
# long-running session has not picked up yet (a tool installed after it started).
function Get-SearchDirectory {
$separator = [IO.Path]::PathSeparator
$directories = [Collections.Generic.List[string]]::new()
$sources = @($env:PATH)
if ($IsWindows) {
foreach ($scope in 'Machine', 'User') {
$sources += [Environment]::GetEnvironmentVariable('Path', $scope)
}
}
foreach ($source in $sources) {
if (-not $source) {
continue
}
foreach ($entry in $source.Split($separator)) {
$expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim())
if ($expanded -and -not $directories.Contains($expanded)) {
$directories.Add($expanded)
}
}
}
return $directories
}
# Every executable called <name> on PATH, in PATH order, store aliases dropped.
function Find-OnPath {
param([string]$Name)
$found = @()
foreach ($directory in (Get-SearchDirectory)) {
foreach ($file in @($Name, "$Name.exe")) {
$candidate = Join-Path $directory $file
if (Test-Usable $candidate) {
$found += $candidate
}
}
}
return $found
}
# The value recorded for <name> in .wikitool-tools.json.
function Get-RecordedPath {
param([string]$Name)
if (-not (Test-Path -LiteralPath $ToolsFile -PathType Leaf)) {
return ''
}
try {
$data = Get-Content -Raw -LiteralPath $ToolsFile | ConvertFrom-Json -AsHashtable
} catch {
return ''
}
if ($data -is [hashtable] -and $data.ContainsKey('tools') -and $data['tools'] -is [hashtable] -and
$data['tools'].ContainsKey($Name)) {
return [string]$data['tools'][$Name]
}
return ''
}
function Get-SetValue {
param([string]$Name)
if ($Sets.ContainsKey($Name)) {
return [string]$Sets[$Name]
}
return ''
}
# --- --set: every named path has to work, or nothing is written -------------------
foreach ($name in $Sets.Keys) {
if ($Tools -notcontains $name) {
Write-ErrorLine "preflight: --set names '$name', which is not a tool this platform needs: $($Tools -join ' ')"
exit 1
}
}
# --- python -------------------------------------------------------------------------
$script:Py = ''
$script:PyVersion = ''
$script:PyOld = $null
$PyMin = Get-ManifestField 'tool' 'python' 3
# Sets Py/PyVersion on success, PyOld when the version is too old.
function Test-PythonCandidate {
param([string]$Path, [string[]]$Extra = @())
$out = Invoke-Native -Path $Path -Arguments ($Extra + @('-c', $PyProbe))
if ($null -eq $out) {
return $false
}
$lines = $out -split "`n"
if ($lines.Count -lt 2) {
return $false
}
$version = $lines[0].Trim()
$executable = $lines[1].Trim()
if (-not $version -or -not $executable) {
return $false
}
if (Test-VersionGe $version $PyMin) {
$script:Py = $executable
$script:PyVersion = $version
return $true
}
$script:PyOld = @{ Version = $version; Path = $Path }
return $false
}
# py and py.exe are the launcher: they need -3
function Get-PythonExtra {
param([string]$Path)
if ((Split-Path -Leaf $Path) -in 'py', 'py.exe') {
return @('-3')
}
return @()
}
$given = Get-SetValue 'python'
if ($given) {
if (-not (Test-Usable $given) -or -not (Test-PythonCandidate $given (Get-PythonExtra $given))) {
Add-Problem "The path given for Python does not work: $given" `
"every wikitool command runs on Python $PyMin or newer, and this path did not start one" `
"Check the path - it has to be the python executable itself, version $PyMin or newer.`nThen run: $Self --set python=<full path to python>"
$script:BadSet = $true
}
} else {
$previous = Get-RecordedPath 'python'
if ($previous -and (Test-Usable $previous)) {
[void](Test-PythonCandidate $previous)
}
if (-not $script:Py) {
if ($Platform -eq 'windows') {
$order = @(@('python', @()), @('py', @('-3')), @('python3', @()))
} else {
$order = @(@('python3', @()), @('python', @()))
}
foreach ($candidate in $order) {
foreach ($path in (Find-OnPath $candidate[0])) {
if (Test-PythonCandidate $path $candidate[1]) {
break
}
}
if ($script:Py) {
break
}
}
}
}
# --- the other tools --------------------------------------------------------------
$Report = @()
$Found = @{}
function Add-Report {
param([string]$Status, [string]$Name, [string]$Version, [string]$Path)
$script:Report += ('{0,-8} {1,-7} {2,-8} {3}' -f $Status, $Name, $Version, $Path)
}
if ($script:Py) {
Add-Report 'OK' 'python' $script:PyVersion $script:Py
$Found['python'] = $script:Py
} elseif (-not $script:BadSet) {
$label = Get-ManifestField 'tool' 'python' 5
$why = Get-ManifestField 'tool' 'python' 6
if ($script:PyOld) {
Add-Report 'TOO_OLD' 'python' $script:PyOld.Version $script:PyOld.Path
Add-Problem "$label $($script:PyOld.Version) is too old - this stack needs $PyMin or newer." $why `
"$(Get-InstallHint 'python')`nOr, if a newer one is already installed, give its full path:`n$Self --set python=<full path to python>"
} else {
Add-Report 'MISSING' 'python' '-' '-'
Add-Problem "$label $PyMin or newer was not found." $why `
"$(Get-InstallHint 'python')`nOr, if it is installed somewhere this check did not look, give its full path:`n$Self --set python=<full path to python>"
}
}
foreach ($tool in $Tools) {
if ($tool -eq 'python') {
continue
}
$label = Get-ManifestField 'tool' $tool 5
$why = Get-ManifestField 'tool' $tool 6
$minimum = Get-ManifestField 'tool' $tool 3
$given = Get-SetValue $tool
$path = ''
if ($given) {
if (Test-Usable $given) {
$path = $given
} else {
Add-Problem "The path given for $label does not work: $given" $why `
"Check the path - it has to be the $tool executable itself.`nThen run: $Self --set $tool=<full path to $tool>"
$script:BadSet = $true
continue
}
} else {
$previous = Get-RecordedPath $tool
if ($tool -eq 'pwsh' -and (Test-Usable ([Environment]::ProcessPath))) {
$path = [Environment]::ProcessPath
} elseif ($previous -and (Test-Usable $previous)) {
$path = $previous
} else {
$path = [string](Find-OnPath $tool | Select-Object -First 1)
}
}
if (-not $path) {
Add-Report 'MISSING' $tool '-' '-'
Add-Problem "$label was not found." $why `
"$(Get-InstallHint $tool)`nOr, if it is installed somewhere this check did not look, give its full path:`n$Self --set $tool=<full path to $tool>"
continue
}
$version = Get-VersionOf ([string](Invoke-Native -Path $path -Arguments @('--version')))
if ($minimum -ne '-' -and (-not $version -or -not (Test-VersionGe $version $minimum))) {
$shown = if ($version) { $version } else { 'unknown' }
Add-Report 'TOO_OLD' $tool $shown $path
Add-Problem "$label $(if ($version) { $version } else { 'of unknown version' }) is too old - this stack needs $minimum or newer." $why `
(Get-InstallHint $tool)
continue
}
Add-Report 'OK' $tool $(if ($version) { $version } else { '-' }) $path
$Found[$tool] = $path
}
# --- install folder length (Windows, long paths off) ------------------------------
if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) {
$limit = [int](Get-ManifestField 'limit' 'install_dir_max' 3)
$length = $Root.Length
if ($length -gt $limit) {
Add-Problem "The folder this wiki is installed in is too long ($length characters, at most $limit): $Root" `
"Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" `
"Move the wiki to a shorter folder, for example C:\Chemenu, and run this check there.`nAlternatively, someone with administrator rights can turn on long paths in Windows."
}
}
# --- execution policy and Mark of the Web (Windows) -------------------------------
# The policy that decides whether tools/wikitool.ps1 starts in an ordinary pwsh: the
# first scope that sets one, the group policies first. This process's own scope is left
# out - it holds the bypass this script was started with.
function Get-PolicyEntry {
if ($env:CHEMENU_PREFLIGHT_POLICY) {
$entries = @()
foreach ($pair in $env:CHEMENU_PREFLIGHT_POLICY.Split(',')) {
$scope, $policy = $pair.Split('=', 2)
$entries += [pscustomobject]@{ Scope = $scope.Trim(); ExecutionPolicy = $policy.Trim() }
}
return $entries
}
return @(Get-ExecutionPolicy -List)
}
function Test-ExecutionPolicy {
$effective = $null
foreach ($scope in 'MachinePolicy', 'UserPolicy', 'CurrentUser', 'LocalMachine') {
$entry = Get-PolicyEntry | Where-Object { [string]$_.Scope -eq $scope } | Select-Object -First 1
if ($entry -and [string]$entry.ExecutionPolicy -ne 'Undefined') {
$effective = @{ Scope = $scope; Policy = [string]$entry.ExecutionPolicy }
break
}
}
if ($null -eq $effective -or $effective.Policy -notin 'Restricted', 'AllSigned') {
return
}
$why = 'tools/wikitool.ps1 is not signed, and this policy only lets signed scripts (or none) run'
if ($effective.Scope -in 'MachinePolicy', 'UserPolicy') {
Add-Problem "A group policy ($($effective.Scope)) sets the PowerShell execution policy to $($effective.Policy)." $why `
"A group policy cannot be overridden from this computer. Ask whoever looks after it to allow locally written scripts (RemoteSigned) for PowerShell 7,`nor run the wiki's commands from Git Bash (tools/wikitool instead of tools/wikitool.ps1)."
} else {
Add-Problem "The PowerShell execution policy is $($effective.Policy) (set for $($effective.Scope))." $why `
"In a PowerShell 7 window, run:`nSet-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned"
}
}
# Scripts below tools/ that carry a Mark of the Web from the internet zone - a file
# saved by a browser or unpacked from such a download in Explorer. Invoke-WebRequest and
# tar set none, so this only turns up on the manual path.
function Get-MarkedScript {
if ($env:CHEMENU_PREFLIGHT_MARKED) {
return @($env:CHEMENU_PREFLIGHT_MARKED.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
}
if (-not $IsWindows) {
return @()
}
$marked = @()
foreach ($file in Get-ChildItem -LiteralPath $Dir -Filter '*.ps1' -Recurse -File) {
if ($file.FullName.StartsWith($Venv, [StringComparison]::OrdinalIgnoreCase)) {
continue
}
$zone = Get-Content -LiteralPath $file.FullName -Stream Zone.Identifier -ErrorAction SilentlyContinue
if ($zone -match 'ZoneId=([3-9])') {
$marked += $file.FullName.Substring($Dir.Length + 1)
}
}
return $marked
}
if ($Platform -eq 'windows') {
Test-ExecutionPolicy
$marked = @(Get-MarkedScript)
if ($marked.Count -gt 0) {
$listed = (($marked | Select-Object -First 5) -join ', ') + $(if ($marked.Count -gt 5) { ', ...' } else { '' })
Add-Problem "Windows marks some of this wiki's scripts as downloaded from the internet: $listed" `
'PowerShell refuses to run a marked script under its usual settings - tools/wikitool.ps1 would not start. This happens when the wiki was downloaded with a browser and unpacked in Explorer' `
"In a PowerShell 7 window, run:`nGet-ChildItem -LiteralPath '$Root' -Recurse -File | Unblock-File"
}
}
# --- record what was found ----------------------------------------------------------
function ConvertTo-JsonString {
param([string]$Text)
return $Text.Replace('\', '\\').Replace('"', '\"')
}
function Write-ToolsFile {
param([bool]$Complete)
$flag = if ($Complete) { 'true' } else { 'false' }
$text = "{`n `"schema`": 1,`n `"complete`": $flag,`n `"tools`": {"
$separator = ''
foreach ($tool in $Tools) {
if ($Found.ContainsKey($tool)) {
$text += "$separator`n `"$tool`": `"$(ConvertTo-JsonString $Found[$tool])`""
$separator = ','
}
}
$text += "`n }`n}`n"
$temporary = "$ToolsFile.tmp.$PID"
[IO.File]::WriteAllText($temporary, $text, [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $temporary -Destination $ToolsFile -Force
}
foreach ($line in $Report) {
Write-Line $line
}
if ($script:BadSet) {
Exit-WithGuide # nothing is written for a path that does not work
}
Write-ToolsFile $false
if ($script:ProblemCount -gt 0) {
Exit-WithGuide
}
# --- tools/.venv ----------------------------------------------------------------------
function Get-VenvPython {
$unix = Join-Path (Join-Path $Venv 'bin') 'python'
$windows = Join-Path (Join-Path $Venv 'Scripts') 'python.exe'
if ((Test-Path -LiteralPath $unix -PathType Leaf) -and (Test-Usable $unix)) {
return $unix
}
if (Test-Path -LiteralPath $windows -PathType Leaf) {
return $windows
}
return ''
}
function Get-LastLine {
param([string]$Text)
return ((($Text -split "`n") | Select-Object -Last 5 | ForEach-Object { " $_" }) -join "`n")
}
$venvPython = Get-VenvPython
if (-not $venvPython -or $null -eq (Invoke-Native -Path $venvPython -Arguments @('-m', 'pip', '--version'))) {
$created = Invoke-NativeVerbose -Path $script:Py -Arguments @('-m', 'venv', '--clear', $Venv)
$venvPython = Get-VenvPython
if ($created.Code -ne 0 -or -not $venvPython) {
$fix = "Python said:`n$(Get-LastLine $created.Output)"
if ($Platform -eq 'linux' -and (Get-Command apt-get -CommandType Application -ErrorAction SilentlyContinue)) {
$fix = "sudo apt install python3-venv`n$fix"
}
Add-Problem 'The wiki''s own Python environment (tools/.venv) could not be created.' `
'wikitool runs in that environment, so that nothing it installs touches the rest of the computer' `
$fix
Exit-WithGuide
}
}
$want = (Get-FileHash -LiteralPath $Requirements -Algorithm SHA256).Hash.ToLowerInvariant()
$have = ''
if (Test-Path -LiteralPath $Stamp -PathType Leaf) {
$have = (Get-Content -Raw -LiteralPath $Stamp).Trim()
}
if ($want -ne $have) {
$installed = Invoke-NativeVerbose -Path $venvPython -Arguments @('-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '-r', $Requirements)
if ($installed.Code -ne 0) {
Add-Problem 'The libraries wikitool needs could not be installed into tools/.venv.' `
'they are downloaded once from the internet; without them no wikitool command starts' `
"Check that this computer can reach the internet (a proxy or a security program can block it; if so, ask whoever looks after this computer).`npip said:`n$(Get-LastLine $installed.Output)"
Exit-WithGuide
}
[IO.File]::WriteAllText($Stamp, "$want`n", [Text.UTF8Encoding]::new($false))
}
Write-Line ('OK venv - {0}' -f $Venv)
Write-ToolsFile $true
Write-Line ''
Write-Line 'Preflight passed. Tool paths are recorded in .wikitool-tools.json; tools/wikitool is ready.'
exit 0