feat: PowerShell 7 preflight and launcher - preflight.ps1, wikitool.ps1, doctor policy and Mark of the Web checks, pwsh CI job (#151, B)
CI / verify (push) Successful in 2m16s
CI / pwsh (push) Successful in 1m24s
Release / release (push) Successful in 37s

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:
torben committed 2026-10-01 09:52:05 +02:00
1 parent 4035b1ba12
commit 210e0c8286
27 files changed
+1513 -32

No files matched your search

+65 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+1
View File
@@ -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
```
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.14
8.0.0-beta.15
+1 -1
View File
@@ -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.
+6
View File
@@ -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:**
+3
View File
@@ -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.
+41 -6
View File
@@ -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.
+6
View File
@@ -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.
+7
View File
@@ -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
View File
@@ -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`.
+6
View File
@@ -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
View File
@@ -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)]
+2
View File
@@ -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)
+1 -1
View File
@@ -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)
+57 -2
View File
@@ -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 "
+99 -3
View File
@@ -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
+2
View File
@@ -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
+3 -2
View File
@@ -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}
]
+71
View File
@@ -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"
+6 -2
View File
@@ -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()
+304
View File
@@ -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
+2 -1
View File
@@ -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):
+705
View File
@@ -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
View File
@@ -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
+54
View File
@@ -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