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
935 lines
37 KiB
PowerShell
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
|