feat: PowerShell 7 preflight and launcher - preflight.ps1, wikitool.ps1, doctor policy and Mark of the Web checks, pwsh CI job (#151, B)
Files changed: - .gitea/workflows/ci.yml - CHANGES.md - INSTALL.md - README.md - VERSION - docs/why-gates-are-code.md - instructions/bootstrap.md - instructions/bug-report.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/bugreport.py - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/doctor.py - tools/chemenu/prerequisites.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_bugreport.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/chemenu/toolpaths.py - tools/preflight.ps1 - tools/wikitool - tools/wikitool.ps1
This commit is contained in:
1 parent
4035b1ba12
commit
210e0c8286
27 files changed
+1513
-32
No files matched your search
+65
-3
@@ -1,8 +1,14 @@
|
||||
# CI for the wiki stack.
|
||||
#
|
||||
# One job, stopping at the first failure - the stack has no artifact to build
|
||||
# and nothing to deploy, so the pipeline's whole job is "does the machinery
|
||||
# still hold together, and does the distribution it produces still work".
|
||||
# `verify` is the pipeline: one job, stopping at the first failure - the stack
|
||||
# has no artifact to build and nothing to deploy, so its whole job is "does the
|
||||
# machinery still hold together, and does the distribution it produces still
|
||||
# work". `pwsh` beside it is the PowerShell half of the same question (Gitea
|
||||
# #151): the same preflight and launcher, under the PowerShell 7 that Windows
|
||||
# harnesses start them with, in the prebuilt `chemenu-ci-pwsh` image
|
||||
# (`pwsh-ci-image.yml`). It runs on Linux, so what only a Windows machine can
|
||||
# answer - the registry, the Store alias, a real Mark of the Web - is covered by
|
||||
# the environment hooks `tools/preflight.ps1` documents, not by this job.
|
||||
#
|
||||
# Runner: `linux-docker` is one of this Gitea instance's three routing labels
|
||||
# (alongside `container-builder` and `k3s-deploy`). The job image is named
|
||||
@@ -327,3 +333,59 @@ jobs:
|
||||
if path.is_file():
|
||||
assert host not in path.read_text(encoding="utf-8", errors="replace"), path
|
||||
PY
|
||||
|
||||
pwsh:
|
||||
runs-on: linux-docker
|
||||
container:
|
||||
image: gitea.nehmer.net/torben/chemenu-ci-pwsh:latest
|
||||
env:
|
||||
WIKITOOL_SESSION_ID: ci-pwsh-${{ github.run_id }}
|
||||
WIKI_TRACE_DIR: /tmp/wikitool-trace
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: PSScriptAnalyzer
|
||||
# Positional arguments are excluded: the rule is written for cmdlets, and the two
|
||||
# scripts call their own small helpers positionally throughout. Everything else the
|
||||
# analyzer knows must stay silent, which includes the ASCII-only and approved-verb rules.
|
||||
run: |
|
||||
set -eu
|
||||
pwsh -NoProfile -Command '
|
||||
$found = foreach ($script in Get-ChildItem tools -Filter *.ps1) {
|
||||
Invoke-ScriptAnalyzer -Path $script.FullName -ExcludeRule PSAvoidUsingPositionalParameters
|
||||
}
|
||||
$found | Format-List RuleName, ScriptName, Line, Message | Out-String -Width 200 | Write-Output
|
||||
if (@($found).Count -gt 0) { exit 1 }
|
||||
'
|
||||
|
||||
- name: Preflight, twice, against the POSIX one
|
||||
# The two preflights answer the same questions from the same list and must write the
|
||||
# same file: that is what keeps a tools/wikitool launched from either shell starting
|
||||
# the same git, rg and Python. The second run must change nothing.
|
||||
run: |
|
||||
set -eu
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
cp .wikitool-tools.json /tmp/tools-pwsh.json
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 > /tmp/preflight-second.txt
|
||||
cmp .wikitool-tools.json /tmp/tools-pwsh.json
|
||||
rm .wikitool-tools.json
|
||||
tools/preflight.sh
|
||||
cmp .wikitool-tools.json /tmp/tools-pwsh.json
|
||||
tools/.venv/bin/python -m pip install --quiet pytest
|
||||
|
||||
- name: The launcher, started from PowerShell
|
||||
# `tools/wikitool` from pwsh resolves to wikitool.ps1, not the sh launcher - the one
|
||||
# thing a Linux shell cannot show, so it is asked for by that exact string.
|
||||
run: |
|
||||
set -eu
|
||||
pwsh -NoProfile -Command './tools/wikitool version show'
|
||||
|
||||
- name: PowerShell tests
|
||||
# Skipped everywhere without pwsh, so this is the run that counts. The `verify` job
|
||||
# runs the rest of the suite.
|
||||
run: |
|
||||
set -eu
|
||||
cd tools
|
||||
.venv/bin/python -m pytest -q chemenu/tests/test_preflight_pwsh.py chemenu/tests/test_preflight.py chemenu/tests/test_doctor.py
|
||||
+36
-2
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 8.0.0-beta.14 - 2026-09-30 - Preflight: prerequisites checked and tool paths recorded before wikitool runs (#151, POSIX half)
|
||||
## 8.0.0-beta.15 - 2026-10-01 - PowerShell 7 preflight and launcher: tools/preflight.ps1, tools/wikitool.ps1, doctor checks for execution policy and Mark of the Web
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
@@ -67,7 +67,7 @@ concern - readable here, never shipped as something to parse.
|
||||
- Page titles must form valid, unique file names on Windows and macOS: new and rename refuse forbidden characters, reserved names (including INDEX and COLLECTION), a trailing dot or space, and titles that collide with another page by case or Unicode normalization; lint reports existing violations as hard errors - rename each affected page with tools/wikitool rename
|
||||
- publish without --no-push now exits 1 before committing when the remote is unreachable or not configured, where it used to commit locally and fail at the push - an offline session or a local-only instance must pass --no-push
|
||||
- new, rename, move and raw accept refuse a target whose path below the instance root is over 160 characters (UTF-16 code units); lint reports existing files over it as Long Paths (advisory) - rename each affected page with tools/wikitool rename, and shorten an incoming/ file name before raw accept
|
||||
- tools/wikitool now refuses to start (exit 42) until tools/preflight.sh has passed in the checkout - after updating, run tools/preflight.sh once: it checks Python, git and ripgrep, records their paths in .wikitool-tools.json and sets up tools/.venv
|
||||
- tools/wikitool now refuses to start (exit 42) until tools/preflight.sh (PowerShell 7: tools/preflight.ps1) has passed in the checkout - after updating, run it once: it checks Python, git and ripgrep, records their paths in .wikitool-tools.json and sets up tools/.venv
|
||||
|
||||
**Migration:** none required - No page format changes; the rule only refuses titles, and each affected page is renamed individually with tools/wikitool rename
|
||||
|
||||
@@ -89,6 +89,7 @@ concern - readable here, never shipped as something to parse.
|
||||
- Bug-report collector can pseudonymise identities, in two stages
|
||||
- publish: the gate lists the staged state; a missing or unreachable remote stops before the commit
|
||||
- Path budget: a file's path stays at 160 characters or fewer so a Windows checkout works without long paths (#163)
|
||||
- PowerShell 7 preflight and launcher: tools/preflight.ps1, tools/wikitool.ps1, doctor checks for execution policy and Mark of the Web
|
||||
|
||||
**Low impact**
|
||||
- version bump no longer points at version release in its output
|
||||
@@ -125,6 +126,39 @@ concern - readable here, never shipped as something to parse.
|
||||
- raw/CONTRACT.md points at the path budget for a name accepted from incoming/
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
### PowerShell 7 preflight and launcher: tools/preflight.ps1, tools/wikitool.ps1, doctor checks for execution policy and Mark of the Web
|
||||
|
||||
The PowerShell half of #151. Harnesses that run in PowerShell 7 on Windows (GitHub Copilot CLI,
|
||||
for one) resolve `tools/wikitool` to `tools/wikitool.ps1` before the sh launcher, so without it
|
||||
the call ended silently. `tools/wikitool.ps1` does what the sh launcher does: it stops with
|
||||
exit 42 until the preflight has written a complete `.wikitool-tools.json`, accepts either venv
|
||||
layout, and passes the CLI's exit code through. There is deliberately no `.cmd`.
|
||||
|
||||
`tools/preflight.ps1` (`#Requires -Version 7`) answers the same questions as `preflight.sh`
|
||||
from the same `tools/prerequisites.txt` and writes the same file, byte for byte - CI compares
|
||||
the two. It is always started as
|
||||
`pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1`: the bypass holds for that
|
||||
one process, changes no setting, and lets the script report a Mark of the Web on itself. On
|
||||
Windows it also reads the machine's `PATH` from the registry, so a session that inherited an
|
||||
old one still finds a tool installed since, and it never runs or records the Microsoft Store
|
||||
alias. What only it checks: the effective execution policy (`Restricted` or `AllSigned` is a
|
||||
stop; when a group policy sets it, the output says that only the administrator can change it
|
||||
and points at Git Bash) and any `*.ps1` under `tools/` carrying a Mark of the Web from the
|
||||
internet zone, with the `Unblock-File` line that fixes it.
|
||||
|
||||
`doctor` gains `execution-policy` and `script-marks` (Windows only, `OK` elsewhere). The
|
||||
launcher's STOP text, `toolpaths.PREFLIGHT`, `doctor`'s fix line, the missing-dependency message
|
||||
and `dist export`'s summary name the PowerShell call beside the sh one. `bugreport.py` starts
|
||||
`wikitool.ps1` with the same bypass, so a blocking policy shows up as a `doctor` finding instead
|
||||
of stopping the report. `instructions/preflight.md` carries both calls, the `--set` form and the
|
||||
two new decision points; `INSTALL.md` has the Windows prerequisite and troubleshooting for the
|
||||
policy and the Mark of the Web.
|
||||
|
||||
The tests run against the same stub machine as the sh ones and skip without `pwsh`. CI gets a
|
||||
`pwsh` job in the new image `chemenu-ci-pwsh` (`.gitea/pwsh-ci/`, built by
|
||||
`pwsh-ci-image.yml`, monthly and on change): PSScriptAnalyzer over `tools/*.ps1`, both
|
||||
preflights compared, `tools/wikitool` started from pwsh, and the PowerShell tests.
|
||||
|
||||
### Preflight: prerequisites checked and tool paths recorded before wikitool runs (#151, POSIX half)
|
||||
|
||||
The Windows install that prompted this found Python missing, then `rg`, and the agent worked
|
||||
|
||||
+19
-3
@@ -17,8 +17,11 @@ Terminal auf dieser Maschine ist.
|
||||
- git
|
||||
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) - wird von `search` und
|
||||
`sources coverage` gebraucht
|
||||
- Nur unter Windows zusätzlich: PowerShell 7 (`pwsh`). Windows PowerShell 5.1 reicht nicht, und
|
||||
WSL ist nicht vorgesehen.
|
||||
|
||||
Ob das alles da ist, prüft der Preflight (`tools/preflight.sh`), bevor irgendein
|
||||
Ob das alles da ist, prüft der Preflight (`tools/preflight.sh`, unter Windows in PowerShell 7
|
||||
`tools/preflight.ps1`), bevor irgendein
|
||||
`wikitool`-Befehl läuft - der erste Schritt jeder Einrichtung, siehe
|
||||
[instructions/preflight.md](instructions/preflight.md). Die maßgebliche Liste steht in
|
||||
`tools/prerequisites.txt`. Fehlt etwas, hält der Agent an und zeigt eine Anleitung mit dem
|
||||
@@ -331,7 +334,8 @@ gefunden hat; `wikitool` startet git und rg von dort statt über `PATH`. Pro Che
|
||||
gitignored - ein Pfad auf einem Rechner sagt über den nächsten nichts. Fehlt die Datei oder ist
|
||||
sie unvollständig, startet `tools/wikitool` nicht (Exit 42) und nennt den Preflight. Liegt ein
|
||||
Tool woanders, als der Preflight sucht, nennt man den Pfad mit
|
||||
`tools/preflight.sh --set rg=<pfad>`; von Hand bearbeitet wird die Datei nicht. `doctor` meldet
|
||||
`tools/preflight.sh --set rg=<pfad>` (PowerShell: `pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 --set rg=<pfad>`); von Hand
|
||||
bearbeitet wird die Datei nicht. `doctor` meldet
|
||||
unter `tool-paths`, ob alle Pfade noch stimmen.
|
||||
|
||||
**Aufgaben-Tracker anbinden - optional.** Der Wochenrückblick (`tools/wikitool review`, Skill
|
||||
@@ -464,7 +468,19 @@ tools/wikitool instructions verify
|
||||
- **`tools/wikitool` endet mit `STOP - this checkout is not set up yet` (Exit 42)** - der
|
||||
Preflight ist in diesem Checkout noch nicht durchgelaufen, oder seit dem letzten Update nicht
|
||||
mehr: `tools/preflight.sh` ausführen, siehe
|
||||
[instructions/preflight.md](instructions/preflight.md).
|
||||
[instructions/preflight.md](instructions/preflight.md). In PowerShell 7 unter Windows heißt der
|
||||
Aufruf `pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1`; das `-ExecutionPolicy Bypass` gilt nur für diesen
|
||||
einen Prozess und ändert keine Einstellung.
|
||||
- **Unter Windows meldet der Preflight die PowerShell-Ausführungsrichtlinie** (`Restricted` oder
|
||||
`AllSigned`) - die Ausgabe nennt die eine Zeile, die man in einem PowerShell-7-Fenster ausführt
|
||||
(`Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned`). Setzt eine
|
||||
Gruppenrichtlinie sie, hilft nur die IT, oder man arbeitet aus Git Bash mit `tools/wikitool`.
|
||||
`doctor` zeigt den Stand unter `execution-policy`.
|
||||
- **Unter Windows meldet der Preflight „Mark of the Web“** - das Repo wurde mit dem Browser
|
||||
geladen und im Explorer entpackt; Windows hält dann jede Datei für „aus dem Internet“ und
|
||||
PowerShell verweigert die Skripte. Einmal im entpackten Ordner, in PowerShell 7:
|
||||
`Get-ChildItem -Recurse -File | Unblock-File`. Wer mit `git clone` oder `Invoke-WebRequest`
|
||||
lädt, hat die Markierung nicht. `doctor` zeigt den Stand unter `script-marks`.
|
||||
- **Der Agent bietet keine Skills an (`wiki-ingest`, `wiki-query`, ...)** - `.agents/skills/`
|
||||
und `.claude/skills/` sind generiert und nicht committet. `tools/wikitool instructions sync`
|
||||
ausführen, dann die Agent-Session neu starten (Harnesses lesen Skills nur beim Start).
|
||||
|
||||
@@ -34,6 +34,7 @@ Two starting points, depending on what you're doing - full walkthrough in [INSTA
|
||||
|
||||
```bash
|
||||
tools/preflight.sh # checks python/git/rg, records their paths, creates tools/.venv
|
||||
# (PowerShell 7 on Windows: tools/preflight.ps1, see instructions/preflight.md)
|
||||
tools/wikitool instructions sync
|
||||
```
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ paragraph it has to remember to apply.
|
||||
The preflight is the second such case, and the one closest to this page's own argument. A machine
|
||||
without Python or ripgrep is not something the tool can fix, and an install that met that gap
|
||||
with only a setup instruction to go on showed what follows: the agent worked around each missing
|
||||
piece - another environment, a hand-made configuration - and kept going. So `tools/preflight.sh`
|
||||
piece - another environment, a hand-made configuration - and kept going. So `tools/preflight.sh` (and its PowerShell twin, `tools/preflight.ps1`)
|
||||
exits 42 with the command a human has to run, and the launcher in front of every `wikitool` call
|
||||
exits 42 until the preflight has passed. "Check first, stop, let the user act" lives in two places
|
||||
a session cannot read past, not in a step it can skip.
|
||||
|
||||
@@ -27,6 +27,12 @@ they are published: the agent harness will not offer `wiki-ingest`, `wiki-query`
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
From PowerShell 7 on Windows, run the twin instead - same questions, same file:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
```
|
||||
|
||||
On exit 42, show its output to the user verbatim and wait.
|
||||
|
||||
2. **Publish the skills:**
|
||||
|
||||
@@ -176,6 +176,9 @@ Facts only: no page content, no guessed cause, no advice.
|
||||
`tree-paths.txt`, which lists every path under `kb/` and `raw/`.
|
||||
- **The collector exits 1?** It could not write the bundle (a missing input file, a full disk). Read
|
||||
the message, fix the cause, retry once, then report the exact error.
|
||||
- **The preflight itself stops, so no Python or venv exists to run the collector?** Its output is
|
||||
the report: take it verbatim from `tools/preflight.sh` or `tools/preflight.ps1`, with the exact
|
||||
command, and add `.wikitool-tools.json` if it was written. Do not install anything to get a bundle.
|
||||
- **A `wikitool` output in the bundle looks wrong or refuses to run?** Do not re-run it to see more.
|
||||
The bundle records what happened; that is the report.
|
||||
|
||||
|
||||
@@ -9,15 +9,28 @@ description: Run the preflight before any wikitool command in a new, cloned, mov
|
||||
exit 42 and names this procedure instead - so there is no skipping it, only running it early
|
||||
or being sent back to it.
|
||||
|
||||
The preflight is a shell script, not a `wikitool` command, because it has to work before
|
||||
Python is known to exist. It does three things, all inside the install folder:
|
||||
The preflight is a script, not a `wikitool` command, because it has to work before Python is
|
||||
known to exist: `tools/preflight.sh` for POSIX shells, `tools/preflight.ps1` for PowerShell 7 on
|
||||
Windows. The two answer the same questions from the same list and write the same
|
||||
`.wikitool-tools.json`. It does three things, all inside the install folder:
|
||||
|
||||
- checks the tools listed in `tools/prerequisites.txt` - Python 3.11 or newer, git, ripgrep
|
||||
(`rg`) - and, on Windows, that the install folder is short enough for Windows' path limit;
|
||||
(`rg`) - and, on Windows, that the install folder is short enough for Windows' path limit and
|
||||
(PowerShell only) that the execution policy and the files' Mark of the Web let
|
||||
`tools/wikitool.ps1` start;
|
||||
- records the absolute path of each tool in `.wikitool-tools.json`, which `wikitool` then starts
|
||||
them from instead of trusting whatever `PATH` a session inherited;
|
||||
- creates `tools/.venv` from the recorded Python and installs `tools/requirements.txt` into it.
|
||||
|
||||
<!-- wikitool:toc -->
|
||||
## Contents
|
||||
|
||||
- [When to run](#when-to-run)
|
||||
- [Steps](#steps)
|
||||
- [Decision points](#decision-points)
|
||||
- [Scope](#scope)
|
||||
<!-- /wikitool:toc -->
|
||||
|
||||
## When to run
|
||||
|
||||
- First step of every installation procedure: [setup-instance.md](setup-instance.md) and
|
||||
@@ -25,20 +38,29 @@ Python is known to exist. It does three things, all inside the install folder:
|
||||
- After every stack update ([upgrade-instance.md](upgrade-instance.md)) - a release can change
|
||||
what the machine needs, or the requirements the venv holds.
|
||||
- Whenever `tools/wikitool` exits 42 and names the preflight, and whenever `tools/wikitool doctor`
|
||||
reports `tool-paths` or `install-dir` as `FAIL`.
|
||||
reports `tool-paths`, `install-dir`, `execution-policy` or `script-marks` as `FAIL`.
|
||||
|
||||
It is safe to run at any time: a second run on a ready checkout changes nothing and exits 0.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Run it** from the root of the checkout:
|
||||
1. **Run it** from the root of the checkout, with the script for the shell the session runs in:
|
||||
|
||||
```bash
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
This covers Linux, macOS and Git Bash on Windows, which is where Claude Code runs its
|
||||
commands there.
|
||||
commands there. From PowerShell 7 on Windows (GitHub Copilot CLI, for one) use the twin, and
|
||||
always with exactly this prefix:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
```
|
||||
|
||||
The bypass holds for that one process only and changes no setting; it is what lets the script
|
||||
run at all when the checkout carries a Mark of the Web, so that it can report that itself.
|
||||
Windows PowerShell 5.1 is not supported.
|
||||
|
||||
2. **Read the exit code.**
|
||||
|
||||
@@ -67,6 +89,10 @@ It is safe to run at any time: a second run on a ready checkout changes nothing
|
||||
tools/preflight.sh --set rg=/opt/ripgrep/rg
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 --set rg=C:\Tools\rg\rg.exe
|
||||
```
|
||||
|
||||
`--set <tool>=<path>` may be given several times. A path that does not work is refused with
|
||||
exit 42 and nothing is written; a working one is recorded and kept on later runs, even though
|
||||
the tool is still not on `PATH`.
|
||||
@@ -77,6 +103,15 @@ It is safe to run at any time: a second run on a ready checkout changes nothing
|
||||
install folder may be at most 95 characters, because every file of the wiki below it has to
|
||||
stay within 259. Moving the wiki to a shorter folder is the user's step; do not try to shorten
|
||||
paths inside the wiki instead.
|
||||
- **The output names the PowerShell execution policy** (`Restricted` or `AllSigned`). The fix is a
|
||||
line the user runs in a PowerShell 7 window; it changes a setting of their account, so it is
|
||||
theirs to run. When a *group policy* sets it, nothing on this computer can override it: the
|
||||
output says to ask whoever administers the machine - or to use `tools/wikitool` from Git Bash
|
||||
instead. Do not suggest a workaround that evades the policy.
|
||||
- **The output names scripts with a Mark of the Web.** The checkout was downloaded with a browser
|
||||
and unpacked in Explorer, so Windows marks every file as coming from the internet. The command
|
||||
in the output (`Unblock-File` over the folder) is the user's to run; a download by
|
||||
`Invoke-WebRequest`, `git clone` or `tar` carries no mark.
|
||||
- **The venv or its libraries could not be installed.** The output carries the last lines of
|
||||
what Python or pip said. A network, proxy or security-product cause is for the user - or
|
||||
whoever administers their machine - to resolve; do not retry with other flags.
|
||||
|
||||
@@ -201,6 +201,12 @@ and ready for its first ingest.
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
From PowerShell 7 on Windows, run the twin instead - same questions, same file:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
```
|
||||
|
||||
On exit 42, show its output to the user verbatim and wait; run it again once they have
|
||||
acted. Continue here only after it exits 0.
|
||||
|
||||
|
||||
@@ -161,6 +161,13 @@ fresh clone ([bootstrap.md](bootstrap.md)), and not for the clone-with-upstream
|
||||
tools/wikitool instructions sync
|
||||
```
|
||||
|
||||
From PowerShell 7 on Windows, run the twin instead - same questions, same file:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
tools/wikitool instructions sync
|
||||
```
|
||||
|
||||
`instructions sync` is needed because the published skill directories are copies: until it
|
||||
runs, the harness is still offering the previous release's skills.
|
||||
|
||||
|
||||
+4
-1
@@ -61,6 +61,8 @@ from the repo root:
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
From PowerShell 7 on Windows, the twin: `pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1`.
|
||||
|
||||
Until it has passed, every `tools/wikitool` call exits 42 and names it.
|
||||
|
||||
## Usage
|
||||
@@ -3221,8 +3223,9 @@ Check that this instance is correctly configured.
|
||||
**NOTES**
|
||||
|
||||
- Checks dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, the kb/raw/reports/work/instructions structure, and generated files.
|
||||
- Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that finished, every tool `tools/prerequisites.txt` names for this platform recorded, and every recorded path still there - a `FAIL` otherwise, fixed by running `tools/preflight.sh` again.
|
||||
- Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that finished, every tool `tools/prerequisites.txt` names for this platform recorded, and every recorded path still there - a `FAIL` otherwise, fixed by running `tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).
|
||||
- Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 characters) - the limit the preflight enforces before it sets anything up.
|
||||
- PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether a group policy sets it, which only whoever administers the computer can change - and a `FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus `tar` do not. `tools/preflight.ps1` checks the same two things before it stops.
|
||||
- Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`.
|
||||
- KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three.
|
||||
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
||||
|
||||
@@ -21,6 +21,10 @@ file deliberately has none - and `docs verify` now enforces that.
|
||||
tools/preflight.sh # from the repo root
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 # PowerShell 7 on Windows
|
||||
```
|
||||
|
||||
The preflight is the one way a checkout gets its environment: it checks the
|
||||
tools listed in `prerequisites.txt`, records their absolute paths in
|
||||
`../.wikitool-tools.json`, creates `.venv` and installs `requirements.txt` into
|
||||
@@ -45,8 +49,10 @@ fix rather than degrading silently.
|
||||
```
|
||||
tools/
|
||||
wikitool entry point (POSIX sh): stops with exit 42 until the preflight has passed
|
||||
wikitool.ps1 the same entry point for PowerShell 7, which resolves `tools/wikitool` to this file first
|
||||
run_wikitool.py what the launcher runs with the venv's Python - puts chemenu on sys.path without PYTHONPATH
|
||||
preflight.sh checks prerequisites.txt, records .wikitool-tools.json, creates .venv (POSIX sh)
|
||||
preflight.ps1 the same for PowerShell 7; also checks the execution policy and the Mark of the Web
|
||||
prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor`
|
||||
trace-hook what the harness hooks call: trace_ingest.py under the venv's Python
|
||||
chemenu/
|
||||
|
||||
+3
-1
@@ -1003,7 +1003,9 @@ def launcher_command(root: Path):
|
||||
ps1, sh = tools / "wikitool.ps1", tools / "wikitool"
|
||||
pwsh = shutil.which("pwsh")
|
||||
if ps1.is_file() and pwsh:
|
||||
return [pwsh, "-NoProfile", "-File", str(ps1)]
|
||||
# Bypass: a policy that blocks the script is a finding for `doctor`'s
|
||||
# execution-policy check, and must not also stop the report from running.
|
||||
return [pwsh, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(ps1)]
|
||||
bash = shutil.which("bash")
|
||||
if sh.is_file() and bash:
|
||||
return [bash, str(sh)]
|
||||
|
||||
@@ -63,6 +63,8 @@ except ModuleNotFoundError as exc:
|
||||
"This is not optional - schema validation depends on it. Run the preflight,\n"
|
||||
"which (re)installs tools/.venv from tools/requirements.txt:\n"
|
||||
" tools/preflight.sh\n"
|
||||
"or, from PowerShell 7:\n"
|
||||
f" {toolpaths.PREFLIGHT_PWSH}\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -1516,7 +1516,7 @@ def run_upgrade(
|
||||
summary += (
|
||||
" Nothing was committed and nothing is verified yet."
|
||||
" `instructions/upgrade-instance.md` carries the order for everything that follows"
|
||||
" and resumes with the preflight (`tools/preflight.sh`), which `tools/wikitool` now"
|
||||
" and resumes with the preflight (`tools/preflight.sh`, or `tools/preflight.ps1` under PowerShell 7), which `tools/wikitool` now"
|
||||
" refuses to run without."
|
||||
)
|
||||
success(summary)
|
||||
@@ -49,7 +49,7 @@ def _git(args: list[str]) -> Optional[subprocess.CompletedProcess]:
|
||||
return None
|
||||
|
||||
|
||||
PREFLIGHT_FIX = "Run tools/preflight.sh"
|
||||
PREFLIGHT_FIX = f"Run tools/preflight.sh (PowerShell 7: {toolpaths.PREFLIGHT_PWSH})"
|
||||
|
||||
|
||||
def check_python() -> Check:
|
||||
@@ -130,6 +130,53 @@ def check_install_dir() -> Check:
|
||||
)
|
||||
|
||||
|
||||
def check_execution_policy() -> Check:
|
||||
"""Whether an ordinary PowerShell 7 may run `tools/wikitool.ps1` - the policy
|
||||
the preflight checks before it stops, so a harness that starts `pwsh`
|
||||
without a bypass is not turned away by a setting nobody looked at."""
|
||||
if prerequisites.platform() != "windows":
|
||||
return Check("execution-policy", "OK", "only PowerShell on Windows has one - not applicable here")
|
||||
policy = prerequisites.execution_policy()
|
||||
if policy is None:
|
||||
return Check(
|
||||
"execution-policy", "WARN", "the PowerShell execution policy could not be read",
|
||||
f"{PREFLIGHT_FIX}; the preflight checks it",
|
||||
)
|
||||
if not policy.blocks:
|
||||
return Check("execution-policy", "OK", policy.describe())
|
||||
if policy.group_policy:
|
||||
return Check(
|
||||
"execution-policy", "FAIL",
|
||||
f"a group policy sets the PowerShell execution policy to {policy.policy} - "
|
||||
"tools/wikitool.ps1 does not start",
|
||||
"A group policy cannot be overridden from this computer: ask whoever looks after it to allow "
|
||||
"locally written scripts (RemoteSigned) for PowerShell 7, or run `tools/wikitool` from Git Bash",
|
||||
)
|
||||
return Check(
|
||||
"execution-policy", "FAIL",
|
||||
f"the PowerShell execution policy is {policy.describe()} - tools/wikitool.ps1 does not start",
|
||||
"In a PowerShell 7 window: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned",
|
||||
)
|
||||
|
||||
|
||||
def check_script_marks() -> Check:
|
||||
"""Mark of the Web on the stack's PowerShell scripts: a wiki downloaded with a
|
||||
browser and unpacked in Explorer carries one, and PowerShell refuses to run
|
||||
a marked script under its usual policy."""
|
||||
if prerequisites.platform() != "windows":
|
||||
return Check("script-marks", "OK", "no Mark of the Web outside Windows")
|
||||
marked = prerequisites.marked_scripts()
|
||||
if not marked:
|
||||
return Check("script-marks", "OK", "no script under tools/ is marked as downloaded")
|
||||
shown = ", ".join(marked[:5]) + (", ..." if len(marked) > 5 else "")
|
||||
return Check(
|
||||
"script-marks", "FAIL",
|
||||
f"Windows marks scripts under tools/ as downloaded from the internet: {shown}",
|
||||
"In a PowerShell 7 window, from the folder holding tools/: "
|
||||
"Get-ChildItem -Recurse -File | Unblock-File",
|
||||
)
|
||||
|
||||
|
||||
def check_author() -> Check:
|
||||
try:
|
||||
author = config.default_author()
|
||||
@@ -669,6 +716,8 @@ def run_doctor() -> list[Check]:
|
||||
check_tool_paths(),
|
||||
check_ripgrep(),
|
||||
check_install_dir(),
|
||||
check_execution_policy(),
|
||||
check_script_marks(),
|
||||
check_author(),
|
||||
check_stack_version(),
|
||||
check_kb_version(),
|
||||
@@ -706,10 +755,16 @@ def run_doctor() -> list[Check]:
|
||||
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
|
||||
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
|
||||
"every recorded path still there - a `FAIL` otherwise, fixed by running "
|
||||
"`tools/preflight.sh` again.",
|
||||
"`tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).",
|
||||
"Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the "
|
||||
"folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 "
|
||||
"characters) - the limit the preflight enforces before it sets anything up.",
|
||||
"PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` "
|
||||
"when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether "
|
||||
"a group policy sets it, which only whoever administers the computer can change - and a "
|
||||
"`FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, "
|
||||
"which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus "
|
||||
"`tar` do not. `tools/preflight.ps1` checks the same two things before it stops.",
|
||||
"Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying "
|
||||
"the template's sentinel is a `FAIL`.",
|
||||
"KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three "
|
||||
|
||||
@@ -8,21 +8,34 @@ preflight enforced up front - a tool whose recorded path has gone, a checkout
|
||||
moved into a folder too long for Windows.
|
||||
|
||||
`CHEMENU_PREFLIGHT_PLATFORM` and `CHEMENU_PREFLIGHT_LONGPATHS` stand in for the
|
||||
real platform and registry, exactly as they do for the preflight scripts; the
|
||||
test suite is their only user.
|
||||
real platform and registry, exactly as they do for the preflight scripts;
|
||||
`CHEMENU_PREFLIGHT_POLICY` and `CHEMENU_PREFLIGHT_MARKED` stand in for the
|
||||
PowerShell execution policy and the Mark of the Web on the stack's scripts the
|
||||
same way (what `tools/preflight.ps1` documents). The test suite is their only
|
||||
user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, titles
|
||||
from chemenu import config, titles, toolpaths
|
||||
|
||||
ENV_PLATFORM = "CHEMENU_PREFLIGHT_PLATFORM"
|
||||
ENV_LONGPATHS = "CHEMENU_PREFLIGHT_LONGPATHS"
|
||||
ENV_POLICY = "CHEMENU_PREFLIGHT_POLICY"
|
||||
ENV_MARKED = "CHEMENU_PREFLIGHT_MARKED"
|
||||
|
||||
# Which scopes decide whether an ordinary pwsh starts tools/wikitool.ps1, strongest first.
|
||||
# `Process` is left out: it holds a bypass the caller started with, not the machine's setting.
|
||||
POLICY_SCOPES = ("MachinePolicy", "UserPolicy", "CurrentUser", "LocalMachine")
|
||||
GROUP_POLICY_SCOPES = ("MachinePolicy", "UserPolicy")
|
||||
BLOCKING_POLICIES = ("Restricted", "AllSigned")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -115,3 +128,86 @@ def install_dir_problem(folder: Optional[str] = None) -> Optional[str]:
|
||||
f"the install folder is {length} characters long and Windows allows at most "
|
||||
f"{limit} here (long paths are off): {folder}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Policy:
|
||||
"""The execution policy an ordinary pwsh applies. `scope` is None when no
|
||||
scope sets one, which on Windows means the default, RemoteSigned."""
|
||||
|
||||
scope: Optional[str]
|
||||
policy: str
|
||||
|
||||
@property
|
||||
def blocks(self) -> bool:
|
||||
return self.policy in BLOCKING_POLICIES
|
||||
|
||||
@property
|
||||
def group_policy(self) -> bool:
|
||||
return self.scope in GROUP_POLICY_SCOPES
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.scope is None:
|
||||
return f"{self.policy} (the default)"
|
||||
return f"{self.policy} (set for {self.scope})"
|
||||
|
||||
|
||||
def _parse_policy_list(text: str) -> Optional[Policy]:
|
||||
"""`Scope=Policy` pairs, separated by commas or lines, to the effective policy."""
|
||||
pairs = {}
|
||||
for item in re.split(r"[,\r\n]+", text):
|
||||
scope, sep, policy = item.partition("=")
|
||||
if sep:
|
||||
pairs[scope.strip()] = policy.strip()
|
||||
if not pairs:
|
||||
return None
|
||||
for scope in POLICY_SCOPES:
|
||||
policy = pairs.get(scope, "Undefined")
|
||||
if policy and policy != "Undefined":
|
||||
return Policy(scope, policy)
|
||||
return Policy(None, "RemoteSigned")
|
||||
|
||||
|
||||
def execution_policy() -> Optional[Policy]:
|
||||
"""The effective PowerShell execution policy, or None when it cannot be
|
||||
read here (not Windows, or no pwsh to ask)."""
|
||||
forced = os.environ.get(ENV_POLICY, "").strip()
|
||||
if forced:
|
||||
return _parse_policy_list(forced)
|
||||
if platform() != "windows" or sys.platform != "win32":
|
||||
return None
|
||||
try: # pragma: no cover - Windows only
|
||||
pwsh = toolpaths.resolve("pwsh")
|
||||
done = subprocess.run(
|
||||
[pwsh, "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-ExecutionPolicy -List | ForEach-Object { '{0}={1}' -f $_.Scope, $_.ExecutionPolicy }"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (toolpaths.ToolPathError, OSError, subprocess.SubprocessError): # pragma: no cover
|
||||
return None
|
||||
if done.returncode != 0: # pragma: no cover - Windows only
|
||||
return None
|
||||
return _parse_policy_list(done.stdout) # pragma: no cover - Windows only
|
||||
|
||||
|
||||
def marked_scripts() -> list[str]:
|
||||
"""PowerShell scripts below `tools/` that carry a Mark of the Web from the
|
||||
internet zone (3 or 4), as names relative to `tools/`. A browser download
|
||||
unpacked in Explorer has them; `Invoke-WebRequest` and `tar` do not."""
|
||||
forced = os.environ.get(ENV_MARKED, "").strip()
|
||||
if forced:
|
||||
return [name.strip() for name in forced.split(",") if name.strip()]
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
tools_dir = config._PACKAGE_ROOT / "tools" # pragma: no cover - Windows only
|
||||
marked = [] # pragma: no cover - Windows only
|
||||
for script in sorted(tools_dir.rglob("*.ps1")): # pragma: no cover - Windows only
|
||||
if ".venv" in script.relative_to(tools_dir).parts:
|
||||
continue
|
||||
try:
|
||||
stream = Path(str(script) + ":Zone.Identifier").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(r"ZoneId=[3-9]", stream):
|
||||
marked.append(script.relative_to(tools_dir).as_posix())
|
||||
return marked # pragma: no cover - Windows only
|
||||
@@ -35,6 +35,8 @@ _WIKITOOL_ENV = (
|
||||
"WIKITOOL_TASKS_CONFIG",
|
||||
"CHEMENU_PREFLIGHT_PLATFORM",
|
||||
"CHEMENU_PREFLIGHT_LONGPATHS",
|
||||
"CHEMENU_PREFLIGHT_POLICY",
|
||||
"CHEMENU_PREFLIGHT_MARKED",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
|
||||
@@ -61,7 +61,7 @@ def checkout(tmp_path: Path, monkeypatch) -> Path:
|
||||
json.dumps({"schema": 1, "api_token": TOKEN, "enabled": True})
|
||||
)
|
||||
(root / ".wikitool-tools.json").write_text(
|
||||
json.dumps({"schema": 1, "tools": {"python": {"path": "/definitely/not/here/python"}}})
|
||||
json.dumps({"schema": 1, "complete": True, "tools": {"python": "/definitely/not/here/python"}})
|
||||
)
|
||||
launcher = root / "tools" / "wikitool"
|
||||
launcher.write_text(LAUNCHER.format(python=sys.executable, title=TITLE))
|
||||
@@ -271,8 +271,9 @@ def test_the_tools_config_path_check_reports_what_is_missing(checkout, tmp_path)
|
||||
bundle = collect(checkout, tmp_path, "--no-trace")
|
||||
config = json.loads((bundle / "environment.json").read_text())["tools_config"]
|
||||
assert config["status"] == "present"
|
||||
assert config["content"]["complete"] is True
|
||||
assert config["path_checks"] == [
|
||||
{"at": "tools.python.path", "path": "/definitely/not/here/python", "exists": False}
|
||||
{"at": "tools.python", "path": "/definitely/not/here/python", "exists": False}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ def _detail(checks, name) -> str:
|
||||
return next(c.detail for c in checks if c.name == name)
|
||||
|
||||
|
||||
def _fix(checks, name) -> str:
|
||||
return next(c.fix or "" for c in checks if c.name == name)
|
||||
|
||||
|
||||
def test_healthy_instance_has_no_fail(instance):
|
||||
checks = doctor.run_doctor()
|
||||
assert not any(c.status == "FAIL" for c in checks)
|
||||
@@ -640,3 +644,70 @@ def test_install_dir_is_not_limited_off_windows(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: "/" + "x" * 300)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == "OK"
|
||||
|
||||
|
||||
# --- PowerShell: execution policy and Mark of the Web (Gitea #151) -----------------
|
||||
|
||||
OPEN_POLICY = "MachinePolicy=Undefined,UserPolicy=Undefined,Process=Undefined,CurrentUser=Undefined,LocalMachine=RemoteSigned"
|
||||
|
||||
|
||||
def _windows(monkeypatch, policy=None, marked=None):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
if policy is not None:
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, policy)
|
||||
if marked is not None:
|
||||
monkeypatch.setenv(prerequisites.ENV_MARKED, marked)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("check", ["execution-policy", "script-marks"])
|
||||
def test_powershell_checks_do_not_apply_off_windows(instance, monkeypatch, check):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, "CurrentUser=AllSigned")
|
||||
monkeypatch.setenv(prerequisites.ENV_MARKED, "wikitool.ps1")
|
||||
assert _status(doctor.run_doctor(), check) == "OK"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy, expected", [
|
||||
(OPEN_POLICY, "OK"),
|
||||
("CurrentUser=Unrestricted,LocalMachine=Restricted", "OK"),
|
||||
("Process=Restricted,LocalMachine=RemoteSigned", "OK"),
|
||||
("CurrentUser=Undefined,LocalMachine=Undefined", "OK"),
|
||||
("CurrentUser=Restricted,LocalMachine=RemoteSigned", "FAIL"),
|
||||
("LocalMachine=AllSigned", "FAIL"),
|
||||
])
|
||||
def test_execution_policy_blocks_only_restricted_and_allsigned(instance, monkeypatch, policy, expected):
|
||||
_windows(monkeypatch, policy=policy)
|
||||
assert _status(doctor.run_doctor(), "execution-policy") == expected
|
||||
|
||||
|
||||
def test_execution_policy_fix_names_the_one_line_command(instance, monkeypatch):
|
||||
_windows(monkeypatch, policy="CurrentUser=Restricted")
|
||||
fix = _fix(doctor.run_doctor(), "execution-policy")
|
||||
assert "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned" in fix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["MachinePolicy", "UserPolicy"])
|
||||
def test_a_group_policy_gets_no_command_it_could_not_obey(instance, monkeypatch, scope):
|
||||
_windows(monkeypatch, policy=f"{scope}=AllSigned,LocalMachine=RemoteSigned")
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "execution-policy") == "FAIL"
|
||||
assert "Set-ExecutionPolicy" not in _fix(checks, "execution-policy")
|
||||
assert "Git Bash" in _fix(checks, "execution-policy")
|
||||
|
||||
|
||||
def test_execution_policy_that_cannot_be_read_is_a_warning(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
assert _status(doctor.run_doctor(), "execution-policy") == "WARN"
|
||||
|
||||
|
||||
def test_marked_scripts_fail_with_the_unblock_command(instance, monkeypatch):
|
||||
_windows(monkeypatch, marked="wikitool.ps1,preflight.ps1")
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "script-marks") == "FAIL"
|
||||
assert "wikitool.ps1" in _detail(checks, "script-marks")
|
||||
assert "Unblock-File" in _fix(checks, "script-marks")
|
||||
|
||||
|
||||
def test_unmarked_scripts_are_ok(instance, monkeypatch):
|
||||
_windows(monkeypatch)
|
||||
assert _status(doctor.run_doctor(), "script-marks") == "OK"
|
||||
@@ -94,7 +94,8 @@ class Machine:
|
||||
self.root = base / root_name
|
||||
self.tools = self.root / "tools"
|
||||
self.tools.mkdir(parents=True)
|
||||
for name in ("preflight.sh", "prerequisites.txt", "wikitool", "run_wikitool.py", "trace-hook"):
|
||||
for name in ("preflight.sh", "preflight.ps1", "prerequisites.txt", "wikitool", "wikitool.ps1",
|
||||
"run_wikitool.py", "trace-hook"):
|
||||
shutil.copy2(TOOLS / name, self.tools / name)
|
||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||
self.sysbin = base / "sysbin"
|
||||
@@ -392,8 +393,11 @@ def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout)
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_there_is_a_powershell_launcher_slot_but_no_cmd():
|
||||
def test_there_is_a_powershell_launcher_and_no_cmd():
|
||||
"""pwsh resolves `tools/wikitool` to `wikitool.ps1` before the sh launcher, and
|
||||
cmd.exe is not a supported shell, so there is no `.cmd`."""
|
||||
assert (TOOLS / "wikitool").is_file()
|
||||
assert (TOOLS / "wikitool.ps1").is_file()
|
||||
assert not (TOOLS / "wikitool.cmd").exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""tools/preflight.ps1 and tools/wikitool.ps1 (Gitea #151, section B).
|
||||
|
||||
The PowerShell twin of `test_preflight.py`, run against the same stub machine: a
|
||||
`PATH` the test builds, stub tools in it, and a fake Python that answers the probe
|
||||
and fakes `-m venv`/`-m pip`. The scripts need PowerShell 7, so these tests skip
|
||||
where there is none and run in CI's `pwsh` job, whose image carries it; everything
|
||||
Windows-specific (policy, Mark of the Web, path limit, Store alias) is driven by the
|
||||
same kind of environment hook the shell script has.
|
||||
|
||||
What these add to the shell tests is the one thing only the twin can break: the two
|
||||
scripts must record the same file. `test_both_preflights_record_the_same_file`
|
||||
compares them byte for byte.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import prerequisites
|
||||
from chemenu.tests.test_preflight import (
|
||||
ECHO_PYTHON, SHELLS, TOOLS, Machine, _machine_with_root_of, assert_guidance,
|
||||
)
|
||||
|
||||
PWSH = shutil.which("pwsh")
|
||||
|
||||
pytestmark = pytest.mark.skipif(PWSH is None, reason="PowerShell 7 (pwsh) is not installed")
|
||||
|
||||
WINDOWS = {"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"}
|
||||
|
||||
|
||||
def _pwsh(machine: Machine, script: str, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {
|
||||
"PATH": os.pathsep.join(str(d) for d in machine.path_dirs),
|
||||
"HOME": str(machine.base),
|
||||
"PIP_LOG": str(machine.pip_log),
|
||||
"DOTNET_CLI_TELEMETRY_OPTOUT": "1",
|
||||
"POWERSHELL_TELEMETRY_OPTOUT": "1",
|
||||
**machine.extra_env,
|
||||
}
|
||||
return subprocess.run(
|
||||
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(machine.tools / script), *args],
|
||||
capture_output=True, text=True, env=env, timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def _preflight(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
return _pwsh(machine, "preflight.ps1", *args)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine(tmp_path: Path) -> Machine:
|
||||
return Machine(tmp_path.resolve()).standard()
|
||||
|
||||
|
||||
# --- the happy path ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine):
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = machine.recorded()
|
||||
assert data["schema"] == 1 and data["complete"] is True
|
||||
assert set(data["tools"]) == {"python", "git", "rg"}
|
||||
assert data["tools"]["rg"] == str(machine.stubs / "rg")
|
||||
assert (machine.tools / ".venv" / "bin" / "python").is_file()
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
assert "Preflight passed" in result.stdout
|
||||
|
||||
|
||||
def test_second_run_changes_nothing(machine):
|
||||
assert _preflight(machine).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
again = _preflight(machine)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
|
||||
def test_both_preflights_record_the_same_file(machine):
|
||||
assert _preflight(machine).returncode == 0
|
||||
from_pwsh = machine.tools_file.read_bytes()
|
||||
machine.tools_file.unlink()
|
||||
assert machine.run(shell=SHELLS[0]).returncode == 0
|
||||
assert machine.tools_file.read_bytes() == from_pwsh
|
||||
|
||||
|
||||
def test_help_prints_the_usage_and_exits_0(machine):
|
||||
result = _preflight(machine, "--help")
|
||||
assert result.returncode == 0
|
||||
assert "--set" in result.stdout
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
# --- stop cases -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_rg_stops_with_42_and_a_missing_line(tmp_path):
|
||||
machine = Machine(tmp_path.resolve()).standard(rg=False)
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert any(line.split()[:2] == ["MISSING", "rg"] for line in result.stdout.splitlines())
|
||||
assert_guidance(result.stdout, "ripgrep (rg) was not found", "--set rg=")
|
||||
data = machine.recorded()
|
||||
assert data["complete"] is False and "rg" not in data["tools"]
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
def test_python_too_old_stops(machine):
|
||||
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert "TOO_OLD" in result.stdout
|
||||
assert_guidance(result.stdout, "Python 3.9 is too old")
|
||||
|
||||
|
||||
def test_invalid_set_path_writes_nothing(machine):
|
||||
result = _preflight(machine, "--set", f"rg={machine.base / 'nowhere' / 'rg'}")
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
def test_valid_set_path_is_recorded_and_kept(machine):
|
||||
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
|
||||
machine.stub("rg", "#!/bin/sh\nexit 1\n")
|
||||
result = _preflight(machine, f"--set=rg={elsewhere}")
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
assert _preflight(machine).returncode == 0
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
|
||||
|
||||
def test_set_for_an_unknown_tool_is_a_usage_error(machine):
|
||||
result = _preflight(machine, "--set", "foo=/bin/sh")
|
||||
assert result.returncode == 1
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
def test_venv_that_cannot_be_created_stops(machine):
|
||||
machine.extra_env["FAKE_VENV_FAIL"] = "1"
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "ensurepip is not available")
|
||||
|
||||
|
||||
def test_pip_failure_stops_and_is_retried_next_time(machine):
|
||||
machine.extra_env["FAKE_PIP_FAIL"] = "1"
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "network unreachable")
|
||||
assert machine.recorded()["complete"] is False
|
||||
del machine.extra_env["FAKE_PIP_FAIL"]
|
||||
assert _preflight(machine).returncode == 0
|
||||
assert machine.recorded()["complete"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
|
||||
def test_store_alias_is_never_run_and_never_recorded(machine, platform, alias):
|
||||
marker = machine.base / "alias-was-run"
|
||||
apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps"
|
||||
machine.stub(alias, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps)
|
||||
machine.path_dirs.insert(0, apps)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
if platform == "windows":
|
||||
machine.python("python")
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 0, result.stdout
|
||||
recorded = machine.recorded()["tools"]["python"]
|
||||
assert "WindowsApps" not in recorded
|
||||
assert recorded.startswith(str(machine.stubs))
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
# --- install folder length (D32) --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", 0),
|
||||
(96, "0", 42),
|
||||
(96, "1", 0),
|
||||
])
|
||||
def test_install_folder_limit_at_the_boundary(tmp_path, length, longpaths, expected):
|
||||
machine = _machine_with_root_of(tmp_path, length)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == expected, result.stdout
|
||||
if expected == 42:
|
||||
assert_guidance(result.stdout, f"too long ({length} characters, at most 95)", "C:\\Chemenu")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
# --- execution policy and Mark of the Web (D16, T6) --------------------------------
|
||||
|
||||
OPEN = "MachinePolicy=Undefined,UserPolicy=Undefined,Process=Undefined,CurrentUser=Undefined,LocalMachine=RemoteSigned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", [
|
||||
OPEN,
|
||||
"CurrentUser=Unrestricted,LocalMachine=Restricted",
|
||||
"Process=Restricted,LocalMachine=RemoteSigned",
|
||||
"CurrentUser=Undefined,LocalMachine=Undefined",
|
||||
])
|
||||
def test_a_policy_that_lets_scripts_run_passes(machine, policy):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": policy})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 0, result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", [
|
||||
"CurrentUser=Restricted,LocalMachine=RemoteSigned",
|
||||
"CurrentUser=AllSigned",
|
||||
"CurrentUser=Undefined,LocalMachine=Restricted",
|
||||
"Process=Bypass,LocalMachine=AllSigned",
|
||||
])
|
||||
def test_a_policy_that_blocks_scripts_stops_with_the_one_line_fix(machine, policy):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": policy})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["MachinePolicy", "UserPolicy"])
|
||||
def test_a_group_policy_is_a_stop_that_names_the_administrator(machine, scope):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": f"{scope}=AllSigned,LocalMachine=RemoteSigned"})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "group policy", "Git Bash")
|
||||
assert "Set-ExecutionPolicy" not in result.stdout
|
||||
|
||||
|
||||
def test_policy_and_marks_are_not_checked_off_windows(machine):
|
||||
machine.extra_env.update({
|
||||
"CHEMENU_PREFLIGHT_PLATFORM": "linux",
|
||||
"CHEMENU_PREFLIGHT_POLICY": "CurrentUser=AllSigned",
|
||||
"CHEMENU_PREFLIGHT_MARKED": "preflight.ps1",
|
||||
})
|
||||
assert _preflight(machine).returncode == 0
|
||||
|
||||
|
||||
def test_a_marked_script_stops_with_the_unblock_command(machine):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_MARKED": "wikitool.ps1,preflight.ps1"})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "wikitool.ps1", "Unblock-File")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
# --- the launcher -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
return _pwsh(machine, "wikitool.ps1", *args)
|
||||
|
||||
|
||||
def _complete_file(machine: Machine, complete: bool = True) -> None:
|
||||
machine.tools_file.write_text(json.dumps(
|
||||
{"schema": 1, "complete": complete, "tools": {"git": "/usr/bin/git"}}, indent=2),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["no file", "no venv", "incomplete"])
|
||||
def test_launcher_stops_with_42_until_the_preflight_has_passed(machine, state):
|
||||
if state != "no file":
|
||||
_complete_file(machine, complete=(state != "incomplete"))
|
||||
if state != "no venv":
|
||||
machine.stub("python", ECHO_PYTHON, machine.tools / ".venv" / "bin")
|
||||
result = _launch(machine, "doctor")
|
||||
assert result.returncode == 42
|
||||
assert "tools/preflight.ps1" in result.stderr
|
||||
assert "ExecutionPolicy Bypass" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout):
|
||||
_complete_file(machine)
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _launch(machine, "doctor", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_launcher_passes_the_exit_code_of_the_cli_through(machine):
|
||||
_complete_file(machine)
|
||||
machine.stub("python", "#!/bin/sh\nexit 7\n", machine.tools / ".venv" / "bin")
|
||||
assert _launch(machine, "lint").returncode == 7
|
||||
|
||||
|
||||
def test_both_launchers_exist_for_pwsh_to_resolve():
|
||||
assert (TOOLS / "wikitool.ps1").is_file() and (TOOLS / "wikitool").is_file()
|
||||
|
||||
|
||||
def test_the_python_side_reads_what_the_hooks_say(monkeypatch):
|
||||
"""The doctor checks use the same hook values the script does; the two
|
||||
parsers must agree on what a policy list means."""
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, "MachinePolicy=AllSigned,LocalMachine=RemoteSigned")
|
||||
policy = prerequisites.execution_policy()
|
||||
assert policy is not None and policy.blocks and policy.group_policy
|
||||
@@ -31,7 +31,8 @@ from chemenu.errors import ChemenuError
|
||||
|
||||
FILE_NAME = ".wikitool-tools.json"
|
||||
SCHEMA = 1
|
||||
PREFLIGHT = "run the preflight again: tools/preflight.sh"
|
||||
PREFLIGHT_PWSH = "pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1"
|
||||
PREFLIGHT = f"run the preflight again: tools/preflight.sh (PowerShell 7: {PREFLIGHT_PWSH})"
|
||||
|
||||
|
||||
class ToolPathError(ChemenuError):
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
#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 <tool>=<path>'
|
||||
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 <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.'
|
||||
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++
|
||||
}
|
||||
|
||||
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 <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
|
||||
}
|
||||
|
||||
# Runs a program; its standard output joined by newlines, or $null when it did not
|
||||
# start or did not exit 0.
|
||||
function Invoke-Native {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Same, but with the error stream merged in, for the messages the user is shown.
|
||||
function Invoke-NativeVerbose {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>&1
|
||||
return [pscustomobject]@{
|
||||
Code = $LASTEXITCODE
|
||||
Output = (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
}
|
||||
} catch {
|
||||
return [pscustomobject]@{ Code = -1; Output = $_.Exception.Message }
|
||||
}
|
||||
}
|
||||
|
||||
# The value recorded for <name> in .wikitool-tools.json.
|
||||
function Get-RecordedPath {
|
||||
param([string]$Name)
|
||||
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) ------------------------------
|
||||
|
||||
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
|
||||
+8
-3
@@ -9,9 +9,10 @@
|
||||
#
|
||||
# Nothing here sets up an environment. tools/preflight.sh does that, once per
|
||||
# checkout and again after every stack update: it records the tool paths in
|
||||
# .wikitool-tools.json and creates tools/.venv. Until it has passed, this script
|
||||
# stops with exit 42 and names it - the preflight is the one step that must not
|
||||
# be skipped or improvised around (instructions/preflight.md).
|
||||
# .wikitool-tools.json and creates tools/.venv (tools/preflight.ps1 is its
|
||||
# PowerShell twin). Until it has passed, this script stops with exit 42 and
|
||||
# names it - the preflight is the one step that must not be skipped or
|
||||
# improvised around (instructions/preflight.md).
|
||||
set -eu
|
||||
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT=$(dirname -- "$DIR")
|
||||
@@ -34,6 +35,10 @@ paths and creates tools/.venv:
|
||||
|
||||
tools/preflight.sh
|
||||
|
||||
From PowerShell 7 (Windows), run this one instead:
|
||||
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
|
||||
It exits 0 when everything is in place. If it exits 42, show its output to the
|
||||
user as it is and wait - it says what to do. See instructions/preflight.md.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#Requires -Version 7
|
||||
# Entry point for the wikitool CLI under PowerShell 7, so agents and people invoke it
|
||||
# through the same string on every platform:
|
||||
#
|
||||
# tools/wikitool <command> [options]
|
||||
#
|
||||
# PowerShell resolves that to this file first. Without it, `tools/wikitool` is the sh
|
||||
# launcher next to it, which PowerShell does not run: the command would end silently
|
||||
# without printing anything. The POSIX half - Linux, macOS and Git Bash - is tools/wikitool.
|
||||
#
|
||||
# Nothing here sets up an environment. tools/preflight.ps1 does that, once per checkout and
|
||||
# again after every stack update: it records the tool paths in .wikitool-tools.json and
|
||||
# creates tools/.venv. Until it has passed, this script stops with exit 42 and names it -
|
||||
# the preflight is the one step that must not be skipped or improvised around
|
||||
# (instructions/preflight.md).
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandArgumentPassing = 'Standard'
|
||||
|
||||
$Dir = $PSScriptRoot
|
||||
$Root = Split-Path -Parent $Dir
|
||||
$ToolsFile = Join-Path $Root '.wikitool-tools.json'
|
||||
|
||||
# Both venv layouts: Scripts\ on Windows, bin/ everywhere else.
|
||||
$Python = ''
|
||||
foreach ($candidate in @((Join-Path $Dir '.venv/Scripts/python.exe'), (Join-Path $Dir '.venv/bin/python'))) {
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
$Python = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$Complete = (Test-Path -LiteralPath $ToolsFile -PathType Leaf) -and
|
||||
((Get-Content -Raw -LiteralPath $ToolsFile) -match '"complete":\s*true')
|
||||
|
||||
if (-not $Python -or -not $Complete) {
|
||||
[Console]::Error.WriteLine(@'
|
||||
STOP - this checkout is not set up yet (or no longer after an update).
|
||||
Run the preflight first; it checks what this computer has, records the tool
|
||||
paths and creates tools/.venv:
|
||||
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
|
||||
It exits 0 when everything is in place. If it exits 42, show its output to the
|
||||
user as it is and wait - it says what to do. See instructions/preflight.md.
|
||||
'@)
|
||||
exit 42
|
||||
}
|
||||
|
||||
# Do not change into $Dir: that would resolve relative CLI arguments (for example
|
||||
# --markdown "kb/Lint Report.md") against tools/ instead of the caller's directory.
|
||||
& $Python (Join-Path $Dir 'run_wikitool.py') @args
|
||||
exit $LASTEXITCODE
|
||||
Reference in new issue
Block a user