#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 # # 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, 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' $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 = @{} $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 =' exit 1 } $index++ $given = [string]$args[$index] } elseif ($arg.StartsWith('--set=')) { $given = $arg.Substring(6) } elseif ($arg -eq '-h' -or $arg -eq '--help') { Write-Line "usage: $Self [--set =]..." 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.' exit 0 } else { Write-ErrorLine "preflight: unknown argument: $arg" exit 1 } $pivot = $given.IndexOf('=') if ($pivot -lt 1) { Write-ErrorLine "preflight: --set needs =, got '$given'" exit 1 } $Sets[$given.Substring(0, $pivot)] = $given.Substring($pivot + 1) $index++ } if (-not (Test-Path -LiteralPath $Manifest -PathType Leaf)) { Write-ErrorLine "preflight: $Manifest is missing - this script has to run from inside an unpacked stack tree." 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 } # --- 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 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 } # Runs a program; its standard output joined by newlines, or $null when it did not # start or did not exit 0. function Invoke-Native { param([string]$Path, [string[]]$Arguments = @()) try { $output = & $Path @Arguments 2>$null if ($LASTEXITCODE -ne 0) { return $null } return (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n") } catch { return $null } } # Same, but with the error stream merged in, for the messages the user is shown. function Invoke-NativeVerbose { param([string]$Path, [string[]]$Arguments = @()) try { $output = & $Path @Arguments 2>&1 return [pscustomobject]@{ Code = $LASTEXITCODE Output = (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n") } } catch { return [pscustomobject]@{ Code = -1; Output = $_.Exception.Message } } } # The value recorded for 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=" $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=" } 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=" } } 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=" $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=" 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) ------------------------------ function Test-LongPathsEnabled { if ($env:CHEMENU_PREFLIGHT_LONGPATHS) { return $env:CHEMENU_PREFLIGHT_LONGPATHS -eq '1' } # An unreadable key counts as "off": the limit then protects a machine it did not # have to. if (-not $IsWindows) { return $false } try { $value = Get-ItemPropertyValue -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled return $value -eq 1 } catch { return $false } } if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) { $limit = [int](Get-ManifestField 'limit' 'install_dir_max' 3) $length = $Root.Length 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