Files
chemenu/tools/preflight.ps1
T
torben a6d07f97c4
CI / verify (push) Successful in 5m19s
CI / pwsh (push) Successful in 1m55s
Release / release (push) Successful in 36s
feat!: installation only from a release, into an empty folder; upstream merge/verify and private-instance.md removed, dist adopt, shell-neutral instructions (#153)
Files changed:
- .gitea/workflows/ci.yml
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- docs/ownership-and-templates.md
- instructions/CONTRACT.md
- instructions/bootstrap.md
- instructions/dev/dev-setup.md
- instructions/dev/stack-dev/SKILL.md
- instructions/gates.md
- instructions/ingest-large-tree.md
- instructions/kb-profiles.md
- instructions/mcp-read-server.md
- instructions/migrations/3.0.0-authoring-conventions.md
- instructions/preflight.md
- instructions/private-instance.md
- instructions/session-setup.md
- instructions/setup-instance.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/config.py
- tools/chemenu/ownership.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_instructions_shell.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/tests/test_preflight_pwsh.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_upstream_cmd.py
- tools/chemenu/toc.py
- tools/preflight.ps1
- tools/preflight.sh
2026-10-01 22:12:09 +02:00

935 lines
37 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 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.
#
# Four 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.
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('\', '/')
}
# Whether the install folder holds anything besides this script and a .git - the two
# things an empty folder may already carry: the script was downloaded into it, and the
# user may have cloned the instance's own, still empty repository there.
function Test-TargetOccupied {
param([string]$Target)
$selfName = Split-Path -Leaf $PSCommandPath
$isOwnFolder = [IO.Path]::GetFullPath($Target).TrimEnd('\', '/') -eq [IO.Path]::GetFullPath($Dir).TrimEnd('\', '/')
foreach ($entry in @(Get-ChildItem -LiteralPath $Target -Force)) {
if ($entry.Name -eq '.git') {
continue
}
if ($isOwnFolder -and $entry.Name -eq $selfName) {
continue
}
return $true
}
return $false
}
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 (Test-TargetOccupied $target)) {
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.'
}
foreach ($entry in @(Get-ChildItem -LiteralPath $unpacked -Force)) {
try {
Move-Item -LiteralPath $entry.FullName -Destination $target
} catch {
Exit-Asset "could not move $($entry.Name) into $target - the folder holds part of the stack now; empty it (keep .git) and run this again."
}
}
} 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