diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c5885b8..644806a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CHANGES.md b/CHANGES.md index ce26740..2341fc7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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/ +### 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 diff --git a/INSTALL.md b/INSTALL.md index a28d860..cd55bda 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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=`; von Hand bearbeitet wird die Datei nicht. `doctor` meldet +`tools/preflight.sh --set rg=` (PowerShell: `pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 --set rg=`); 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). diff --git a/README.md b/README.md index 2e2c168..8b81e24 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/VERSION b/VERSION index eb8b40a..95f0f56 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.14 +8.0.0-beta.15 diff --git a/docs/why-gates-are-code.md b/docs/why-gates-are-code.md index be75e16..2e1442b 100644 --- a/docs/why-gates-are-code.md +++ b/docs/why-gates-are-code.md @@ -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. diff --git a/instructions/bootstrap.md b/instructions/bootstrap.md index 13da528..a3d284b 100644 --- a/instructions/bootstrap.md +++ b/instructions/bootstrap.md @@ -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:** diff --git a/instructions/bug-report.md b/instructions/bug-report.md index c74ac09..0350d73 100644 --- a/instructions/bug-report.md +++ b/instructions/bug-report.md @@ -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. diff --git a/instructions/preflight.md b/instructions/preflight.md index 1896622..8c02804 100644 --- a/instructions/preflight.md +++ b/instructions/preflight.md @@ -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. + +## Contents + +- [When to run](#when-to-run) +- [Steps](#steps) +- [Decision points](#decision-points) +- [Scope](#scope) + + ## 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 =` 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. diff --git a/instructions/setup-instance.md b/instructions/setup-instance.md index 5ea4de8..6cbdb0e 100644 --- a/instructions/setup-instance.md +++ b/instructions/setup-instance.md @@ -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. diff --git a/instructions/upgrade-instance.md b/instructions/upgrade-instance.md index 8d15fbb..17bf079 100644 --- a/instructions/upgrade-instance.md +++ b/instructions/upgrade-instance.md @@ -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. diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index fccf320..430805e 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -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`. diff --git a/tools/README.md b/tools/README.md index d2c2493..c54633f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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/ diff --git a/tools/bugreport.py b/tools/bugreport.py index 8dd2aa4..39b943c 100644 --- a/tools/bugreport.py +++ b/tools/bugreport.py @@ -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)] diff --git a/tools/chemenu/cli.py b/tools/chemenu/cli.py index 36189da..8853f5b 100644 --- a/tools/chemenu/cli.py +++ b/tools/chemenu/cli.py @@ -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) diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index 14cb56a..44c428e 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -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) diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index e239762..17d46e8 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -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 " diff --git a/tools/chemenu/prerequisites.py b/tools/chemenu/prerequisites.py index 853de7c..d5c2cee 100644 --- a/tools/chemenu/prerequisites.py +++ b/tools/chemenu/prerequisites.py @@ -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 diff --git a/tools/chemenu/tests/conftest.py b/tools/chemenu/tests/conftest.py index bac0aab..2bd1a0a 100644 --- a/tools/chemenu/tests/conftest.py +++ b/tools/chemenu/tests/conftest.py @@ -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 diff --git a/tools/chemenu/tests/test_bugreport.py b/tools/chemenu/tests/test_bugreport.py index 4d74520..051d77b 100644 --- a/tools/chemenu/tests/test_bugreport.py +++ b/tools/chemenu/tests/test_bugreport.py @@ -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} ] diff --git a/tools/chemenu/tests/test_doctor.py b/tools/chemenu/tests/test_doctor.py index a9e528c..97e3130 100644 --- a/tools/chemenu/tests/test_doctor.py +++ b/tools/chemenu/tests/test_doctor.py @@ -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" diff --git a/tools/chemenu/tests/test_preflight.py b/tools/chemenu/tests/test_preflight.py index fb7cd58..4040a44 100644 --- a/tools/chemenu/tests/test_preflight.py +++ b/tools/chemenu/tests/test_preflight.py @@ -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() diff --git a/tools/chemenu/tests/test_preflight_pwsh.py b/tools/chemenu/tests/test_preflight_pwsh.py new file mode 100644 index 0000000..6736e22 --- /dev/null +++ b/tools/chemenu/tests/test_preflight_pwsh.py @@ -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 diff --git a/tools/chemenu/toolpaths.py b/tools/chemenu/toolpaths.py index cf513e0..651e504 100644 --- a/tools/chemenu/toolpaths.py +++ b/tools/chemenu/toolpaths.py @@ -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): diff --git a/tools/preflight.ps1 b/tools/preflight.ps1 new file mode 100644 index 0000000..f86f008 --- /dev/null +++ b/tools/preflight.ps1 @@ -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 =' + exit 1 + } + $index++ + $given = [string]$args[$index] + } elseif ($arg.StartsWith('--set=')) { + $given = $arg.Substring(6) + } elseif ($arg -eq '-h' -or $arg -eq '--help') { + Write-Line "usage: $Self [--set =]..." + Write-Line '' + Write-Line 'Checks the tools this stack needs (tools/prerequisites.txt), records their paths' + Write-Line 'in .wikitool-tools.json and sets up tools/.venv. Exit 0 means ready; exit 42' + Write-Line 'means the user has to act - the output says how.' + exit 0 + } else { + Write-ErrorLine "preflight: unknown argument: $arg" + exit 1 + } + $pivot = $given.IndexOf('=') + if ($pivot -lt 1) { + Write-ErrorLine "preflight: --set needs =, got '$given'" + exit 1 + } + $Sets[$given.Substring(0, $pivot)] = $given.Substring($pivot + 1) + $index++ +} + +if (-not (Test-Path -LiteralPath $Manifest -PathType Leaf)) { + Write-ErrorLine "preflight: $Manifest is missing - this script has to run from inside an unpacked stack tree." + exit 1 +} + +# --- platform ----------------------------------------------------------------- + +if ($env:CHEMENU_PREFLIGHT_PLATFORM) { + $Platform = $env:CHEMENU_PREFLIGHT_PLATFORM +} elseif ($IsWindows) { + $Platform = 'windows' +} elseif ($IsMacOS) { + $Platform = 'macos' +} else { + $Platform = 'linux' +} + +# --- problems and the guidance block -------------------------------------------- + +$script:ProblemCount = 0 +$script:Guide = '' +$script:BadSet = $false + +function Add-Problem { + param([string]$What, [string]$Why, [string]$Fix) + $script:ProblemCount++ + $fixText = ($Fix -split "`n") -join "`n " + $script:Guide += "`n$($script:ProblemCount)) $What`n Why: $Why`n Fix: $fixText`n Next: Tell the agent once this is done - it runs this check again.`n" +} + +function Exit-WithGuide { + if ($script:ProblemCount -eq 1) { + $noun = '1 thing needs' + } else { + $noun = "$($script:ProblemCount) things need" + } + Write-Line '' + Write-Line "STOP - $noun your attention before this wiki can run." + Write-Line '(Agent: show this output to the user exactly as it is, then wait. Do not install' + Write-Line 'anything yourself and do not work around it.)' + [Console]::Out.Write($script:Guide) + exit 42 +} + +# --- the manifest --------------------------------------------------------------- + +$ManifestLines = @( + Get-Content -LiteralPath $Manifest -Encoding utf8 | + ForEach-Object { $_.TrimEnd("`r") } | + Where-Object { $_.Trim() -ne '' -and -not $_.StartsWith('#') } +) + +function Get-ManifestField { + param([string]$Kind, [string]$Name, [int]$Field) + foreach ($line in $ManifestLines) { + $parts = $line.Split('|') + if ($parts.Count -gt 1 -and $parts[0] -eq $Kind -and $parts[1] -eq $Name) { + if ($Field -le $parts.Count) { + return $parts[$Field - 1] + } + return '' + } + } + return '' +} + +$Tools = @() +foreach ($line in $ManifestLines) { + $parts = $line.Split('|') + if ($parts[0] -ne 'tool') { + continue + } + if ($parts[1] -notmatch '^[a-z0-9]+$') { + Write-ErrorLine "preflight: bad tool name '$($parts[1])' in $Manifest" + exit 1 + } + if ($parts[3] -eq 'all' -or $parts[3] -eq $Platform) { + $Tools += $parts[1] + } +} + +function Get-InstallHint { + param([string]$Tool) + $choco = Get-ManifestField 'tool' $Tool 7 + $winget = Get-ManifestField 'tool' $Tool 8 + $brew = Get-ManifestField 'tool' $Tool 9 + $apt = Get-ManifestField 'tool' $Tool 10 + $pacman = Get-ManifestField 'tool' $Tool 11 + $hint = '' + switch ($Platform) { + 'windows' { + if ((Get-Command choco -CommandType Application -ErrorAction SilentlyContinue) -and $choco -ne '-') { + $hint = "$choco (in a terminal opened as administrator)" + } elseif ((Get-Command winget -CommandType Application -ErrorAction SilentlyContinue) -and $winget -ne '-') { + $hint = $winget + } + } + 'macos' { + if ((Get-Command brew -CommandType Application -ErrorAction SilentlyContinue) -and $brew -ne '-') { + $hint = $brew + } + } + default { + if ((Get-Command apt-get -CommandType Application -ErrorAction SilentlyContinue) -and $apt -ne '-') { + $hint = $apt + } elseif ((Get-Command pacman -CommandType Application -ErrorAction SilentlyContinue) -and $pacman -ne '-') { + $hint = $pacman + } + } + } + if ($hint) { + return $hint + } + $lines = @('Install it with the package manager this computer uses, for example:') + foreach ($entry in @($choco, $winget, $brew, $apt, $pacman)) { + if ($entry -and $entry -ne '-') { + $lines += " $entry" + } + } + return ($lines -join "`n") +} + +# --- version helpers ------------------------------------------------------------ + +# major.minor of the first version-looking token in the text ("git version 2.47.1" -> 2.47) +function Get-VersionOf { + param([string]$Text) + $first = ($Text -split "`n" | Select-Object -First 1) + if ($null -eq $first) { + return '' + } + $match = [regex]::Match($first, '(\d+)(?:\.(\d+))?') + if (-not $match.Success) { + return '' + } + if ($match.Groups[2].Success) { + return "$($match.Groups[1].Value).$($match.Groups[2].Value)" + } + return $match.Groups[1].Value +} + +function Test-VersionGe { + param([string]$Have, [string]$Want) + $haveParts = ("$Have.0" -split '\.')[0, 1] | ForEach-Object { [int]$_ } + $wantParts = ("$Want.0" -split '\.')[0, 1] | ForEach-Object { [int]$_ } + if ($haveParts[0] -ne $wantParts[0]) { + return $haveParts[0] -gt $wantParts[0] + } + return $haveParts[1] -ge $wantParts[1] +} + +# --- finding tools -------------------------------------------------------------- + +# The Microsoft Store's app-execution aliases: a python.exe that opens the Store +# instead of running anything. Never executed, never recorded. +function Test-StoreAlias { + param([string]$Path) + return $Path -match '(?i)[\\/]windowsapps[\\/]' +} + +function Test-Usable { + param([string]$Path) + if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf) -or (Test-StoreAlias $Path)) { + return $false + } + if ($IsWindows) { + return $true + } + return (([int][IO.File]::GetUnixFileMode($Path)) -band 73) -ne 0 +} + +# PATH as this process has it, plus - on Windows - whatever the registry holds that a +# long-running session has not picked up yet (a tool installed after it started). +function Get-SearchDirectory { + $separator = [IO.Path]::PathSeparator + $directories = [Collections.Generic.List[string]]::new() + $sources = @($env:PATH) + if ($IsWindows) { + foreach ($scope in 'Machine', 'User') { + $sources += [Environment]::GetEnvironmentVariable('Path', $scope) + } + } + foreach ($source in $sources) { + if (-not $source) { + continue + } + foreach ($entry in $source.Split($separator)) { + $expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim()) + if ($expanded -and -not $directories.Contains($expanded)) { + $directories.Add($expanded) + } + } + } + return $directories +} + +# Every executable called on PATH, in PATH order, store aliases dropped. +function Find-OnPath { + param([string]$Name) + $found = @() + foreach ($directory in (Get-SearchDirectory)) { + foreach ($file in @($Name, "$Name.exe")) { + $candidate = Join-Path $directory $file + if (Test-Usable $candidate) { + $found += $candidate + } + } + } + return $found +} + +# Runs a program; its standard output joined by newlines, or $null when it did not +# start or did not exit 0. +function Invoke-Native { + param([string]$Path, [string[]]$Arguments = @()) + try { + $output = & $Path @Arguments 2>$null + if ($LASTEXITCODE -ne 0) { + return $null + } + return (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n") + } catch { + return $null + } +} + +# Same, but with the error stream merged in, for the messages the user is shown. +function Invoke-NativeVerbose { + param([string]$Path, [string[]]$Arguments = @()) + try { + $output = & $Path @Arguments 2>&1 + return [pscustomobject]@{ + Code = $LASTEXITCODE + Output = (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n") + } + } catch { + return [pscustomobject]@{ Code = -1; Output = $_.Exception.Message } + } +} + +# The value recorded for in .wikitool-tools.json. +function Get-RecordedPath { + param([string]$Name) + if (-not (Test-Path -LiteralPath $ToolsFile -PathType Leaf)) { + return '' + } + try { + $data = Get-Content -Raw -LiteralPath $ToolsFile | ConvertFrom-Json -AsHashtable + } catch { + return '' + } + if ($data -is [hashtable] -and $data.ContainsKey('tools') -and $data['tools'] -is [hashtable] -and + $data['tools'].ContainsKey($Name)) { + return [string]$data['tools'][$Name] + } + return '' +} + +function Get-SetValue { + param([string]$Name) + if ($Sets.ContainsKey($Name)) { + return [string]$Sets[$Name] + } + return '' +} + +# --- --set: every named path has to work, or nothing is written ------------------- + +foreach ($name in $Sets.Keys) { + if ($Tools -notcontains $name) { + Write-ErrorLine "preflight: --set names '$name', which is not a tool this platform needs: $($Tools -join ' ')" + exit 1 + } +} + +# --- python ------------------------------------------------------------------------- + +$script:Py = '' +$script:PyVersion = '' +$script:PyOld = $null +$PyMin = Get-ManifestField 'tool' 'python' 3 + +# Sets Py/PyVersion on success, PyOld when the version is too old. +function Test-PythonCandidate { + param([string]$Path, [string[]]$Extra = @()) + $out = Invoke-Native -Path $Path -Arguments ($Extra + @('-c', $PyProbe)) + if ($null -eq $out) { + return $false + } + $lines = $out -split "`n" + if ($lines.Count -lt 2) { + return $false + } + $version = $lines[0].Trim() + $executable = $lines[1].Trim() + if (-not $version -or -not $executable) { + return $false + } + if (Test-VersionGe $version $PyMin) { + $script:Py = $executable + $script:PyVersion = $version + return $true + } + $script:PyOld = @{ Version = $version; Path = $Path } + return $false +} + +# py and py.exe are the launcher: they need -3 +function Get-PythonExtra { + param([string]$Path) + if ((Split-Path -Leaf $Path) -in 'py', 'py.exe') { + return @('-3') + } + return @() +} + +$given = Get-SetValue 'python' +if ($given) { + if (-not (Test-Usable $given) -or -not (Test-PythonCandidate $given (Get-PythonExtra $given))) { + Add-Problem "The path given for Python does not work: $given" ` + "every wikitool command runs on Python $PyMin or newer, and this path did not start one" ` + "Check the path - it has to be the python executable itself, version $PyMin or newer.`nThen run: $Self --set python=" + $script:BadSet = $true + } +} else { + $previous = Get-RecordedPath 'python' + if ($previous -and (Test-Usable $previous)) { + [void](Test-PythonCandidate $previous) + } + if (-not $script:Py) { + if ($Platform -eq 'windows') { + $order = @(@('python', @()), @('py', @('-3')), @('python3', @())) + } else { + $order = @(@('python3', @()), @('python', @())) + } + foreach ($candidate in $order) { + foreach ($path in (Find-OnPath $candidate[0])) { + if (Test-PythonCandidate $path $candidate[1]) { + break + } + } + if ($script:Py) { + break + } + } + } +} + +# --- the other tools -------------------------------------------------------------- + +$Report = @() +$Found = @{} + +function Add-Report { + param([string]$Status, [string]$Name, [string]$Version, [string]$Path) + $script:Report += ('{0,-8} {1,-7} {2,-8} {3}' -f $Status, $Name, $Version, $Path) +} + +if ($script:Py) { + Add-Report 'OK' 'python' $script:PyVersion $script:Py + $Found['python'] = $script:Py +} elseif (-not $script:BadSet) { + $label = Get-ManifestField 'tool' 'python' 5 + $why = Get-ManifestField 'tool' 'python' 6 + if ($script:PyOld) { + Add-Report 'TOO_OLD' 'python' $script:PyOld.Version $script:PyOld.Path + Add-Problem "$label $($script:PyOld.Version) is too old - this stack needs $PyMin or newer." $why ` + "$(Get-InstallHint 'python')`nOr, if a newer one is already installed, give its full path:`n$Self --set python=" + } else { + Add-Report 'MISSING' 'python' '-' '-' + Add-Problem "$label $PyMin or newer was not found." $why ` + "$(Get-InstallHint 'python')`nOr, if it is installed somewhere this check did not look, give its full path:`n$Self --set python=" + } +} + +foreach ($tool in $Tools) { + if ($tool -eq 'python') { + continue + } + $label = Get-ManifestField 'tool' $tool 5 + $why = Get-ManifestField 'tool' $tool 6 + $minimum = Get-ManifestField 'tool' $tool 3 + $given = Get-SetValue $tool + $path = '' + if ($given) { + if (Test-Usable $given) { + $path = $given + } else { + Add-Problem "The path given for $label does not work: $given" $why ` + "Check the path - it has to be the $tool executable itself.`nThen run: $Self --set $tool=" + $script:BadSet = $true + continue + } + } else { + $previous = Get-RecordedPath $tool + if ($tool -eq 'pwsh' -and (Test-Usable ([Environment]::ProcessPath))) { + $path = [Environment]::ProcessPath + } elseif ($previous -and (Test-Usable $previous)) { + $path = $previous + } else { + $path = [string](Find-OnPath $tool | Select-Object -First 1) + } + } + if (-not $path) { + Add-Report 'MISSING' $tool '-' '-' + Add-Problem "$label was not found." $why ` + "$(Get-InstallHint $tool)`nOr, if it is installed somewhere this check did not look, give its full path:`n$Self --set $tool=" + continue + } + $version = Get-VersionOf ([string](Invoke-Native -Path $path -Arguments @('--version'))) + if ($minimum -ne '-' -and (-not $version -or -not (Test-VersionGe $version $minimum))) { + $shown = if ($version) { $version } else { 'unknown' } + Add-Report 'TOO_OLD' $tool $shown $path + Add-Problem "$label $(if ($version) { $version } else { 'of unknown version' }) is too old - this stack needs $minimum or newer." $why ` + (Get-InstallHint $tool) + continue + } + Add-Report 'OK' $tool $(if ($version) { $version } else { '-' }) $path + $Found[$tool] = $path +} + +# --- install folder length (Windows, long paths off) ------------------------------ + +function Test-LongPathsEnabled { + if ($env:CHEMENU_PREFLIGHT_LONGPATHS) { + return $env:CHEMENU_PREFLIGHT_LONGPATHS -eq '1' + } + # An unreadable key counts as "off": the limit then protects a machine it did not + # have to. + if (-not $IsWindows) { + return $false + } + try { + $value = Get-ItemPropertyValue -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled + return $value -eq 1 + } catch { + return $false + } +} + +if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) { + $limit = [int](Get-ManifestField 'limit' 'install_dir_max' 3) + $length = $Root.Length + if ($length -gt $limit) { + Add-Problem "The folder this wiki is installed in is too long ($length characters, at most $limit): $Root" ` + "Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" ` + "Move the wiki to a shorter folder, for example C:\Chemenu, and run this check there.`nAlternatively, someone with administrator rights can turn on long paths in Windows." + } +} + +# --- execution policy and Mark of the Web (Windows) ------------------------------- + +# The policy that decides whether tools/wikitool.ps1 starts in an ordinary pwsh: the +# first scope that sets one, the group policies first. This process's own scope is left +# out - it holds the bypass this script was started with. +function Get-PolicyEntry { + if ($env:CHEMENU_PREFLIGHT_POLICY) { + $entries = @() + foreach ($pair in $env:CHEMENU_PREFLIGHT_POLICY.Split(',')) { + $scope, $policy = $pair.Split('=', 2) + $entries += [pscustomobject]@{ Scope = $scope.Trim(); ExecutionPolicy = $policy.Trim() } + } + return $entries + } + return @(Get-ExecutionPolicy -List) +} + +function Test-ExecutionPolicy { + $effective = $null + foreach ($scope in 'MachinePolicy', 'UserPolicy', 'CurrentUser', 'LocalMachine') { + $entry = Get-PolicyEntry | Where-Object { [string]$_.Scope -eq $scope } | Select-Object -First 1 + if ($entry -and [string]$entry.ExecutionPolicy -ne 'Undefined') { + $effective = @{ Scope = $scope; Policy = [string]$entry.ExecutionPolicy } + break + } + } + if ($null -eq $effective -or $effective.Policy -notin 'Restricted', 'AllSigned') { + return + } + $why = 'tools/wikitool.ps1 is not signed, and this policy only lets signed scripts (or none) run' + if ($effective.Scope -in 'MachinePolicy', 'UserPolicy') { + Add-Problem "A group policy ($($effective.Scope)) sets the PowerShell execution policy to $($effective.Policy)." $why ` + "A group policy cannot be overridden from this computer. Ask whoever looks after it to allow locally written scripts (RemoteSigned) for PowerShell 7,`nor run the wiki's commands from Git Bash (tools/wikitool instead of tools/wikitool.ps1)." + } else { + Add-Problem "The PowerShell execution policy is $($effective.Policy) (set for $($effective.Scope))." $why ` + "In a PowerShell 7 window, run:`nSet-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned" + } +} + +# Scripts below tools/ that carry a Mark of the Web from the internet zone - a file +# saved by a browser or unpacked from such a download in Explorer. Invoke-WebRequest and +# tar set none, so this only turns up on the manual path. +function Get-MarkedScript { + if ($env:CHEMENU_PREFLIGHT_MARKED) { + return @($env:CHEMENU_PREFLIGHT_MARKED.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + if (-not $IsWindows) { + return @() + } + $marked = @() + foreach ($file in Get-ChildItem -LiteralPath $Dir -Filter '*.ps1' -Recurse -File) { + if ($file.FullName.StartsWith($Venv, [StringComparison]::OrdinalIgnoreCase)) { + continue + } + $zone = Get-Content -LiteralPath $file.FullName -Stream Zone.Identifier -ErrorAction SilentlyContinue + if ($zone -match 'ZoneId=([3-9])') { + $marked += $file.FullName.Substring($Dir.Length + 1) + } + } + return $marked +} + +if ($Platform -eq 'windows') { + Test-ExecutionPolicy + $marked = @(Get-MarkedScript) + if ($marked.Count -gt 0) { + $listed = (($marked | Select-Object -First 5) -join ', ') + $(if ($marked.Count -gt 5) { ', ...' } else { '' }) + Add-Problem "Windows marks some of this wiki's scripts as downloaded from the internet: $listed" ` + 'PowerShell refuses to run a marked script under its usual settings - tools/wikitool.ps1 would not start. This happens when the wiki was downloaded with a browser and unpacked in Explorer' ` + "In a PowerShell 7 window, run:`nGet-ChildItem -LiteralPath '$Root' -Recurse -File | Unblock-File" + } +} + +# --- record what was found ---------------------------------------------------------- + +function ConvertTo-JsonString { + param([string]$Text) + return $Text.Replace('\', '\\').Replace('"', '\"') +} + +function Write-ToolsFile { + param([bool]$Complete) + $flag = if ($Complete) { 'true' } else { 'false' } + $text = "{`n `"schema`": 1,`n `"complete`": $flag,`n `"tools`": {" + $separator = '' + foreach ($tool in $Tools) { + if ($Found.ContainsKey($tool)) { + $text += "$separator`n `"$tool`": `"$(ConvertTo-JsonString $Found[$tool])`"" + $separator = ',' + } + } + $text += "`n }`n}`n" + $temporary = "$ToolsFile.tmp.$PID" + [IO.File]::WriteAllText($temporary, $text, [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporary -Destination $ToolsFile -Force +} + +foreach ($line in $Report) { + Write-Line $line +} + +if ($script:BadSet) { + Exit-WithGuide # nothing is written for a path that does not work +} +Write-ToolsFile $false +if ($script:ProblemCount -gt 0) { + Exit-WithGuide +} + +# --- tools/.venv ---------------------------------------------------------------------- + +function Get-VenvPython { + $unix = Join-Path (Join-Path $Venv 'bin') 'python' + $windows = Join-Path (Join-Path $Venv 'Scripts') 'python.exe' + if ((Test-Path -LiteralPath $unix -PathType Leaf) -and (Test-Usable $unix)) { + return $unix + } + if (Test-Path -LiteralPath $windows -PathType Leaf) { + return $windows + } + return '' +} + +function Get-LastLine { + param([string]$Text) + return ((($Text -split "`n") | Select-Object -Last 5 | ForEach-Object { " $_" }) -join "`n") +} + +$venvPython = Get-VenvPython +if (-not $venvPython -or $null -eq (Invoke-Native -Path $venvPython -Arguments @('-m', 'pip', '--version'))) { + $created = Invoke-NativeVerbose -Path $script:Py -Arguments @('-m', 'venv', '--clear', $Venv) + $venvPython = Get-VenvPython + if ($created.Code -ne 0 -or -not $venvPython) { + $fix = "Python said:`n$(Get-LastLine $created.Output)" + if ($Platform -eq 'linux' -and (Get-Command apt-get -CommandType Application -ErrorAction SilentlyContinue)) { + $fix = "sudo apt install python3-venv`n$fix" + } + Add-Problem 'The wiki''s own Python environment (tools/.venv) could not be created.' ` + 'wikitool runs in that environment, so that nothing it installs touches the rest of the computer' ` + $fix + Exit-WithGuide + } +} + +$want = (Get-FileHash -LiteralPath $Requirements -Algorithm SHA256).Hash.ToLowerInvariant() +$have = '' +if (Test-Path -LiteralPath $Stamp -PathType Leaf) { + $have = (Get-Content -Raw -LiteralPath $Stamp).Trim() +} +if ($want -ne $have) { + $installed = Invoke-NativeVerbose -Path $venvPython -Arguments @('-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '-r', $Requirements) + if ($installed.Code -ne 0) { + Add-Problem 'The libraries wikitool needs could not be installed into tools/.venv.' ` + 'they are downloaded once from the internet; without them no wikitool command starts' ` + "Check that this computer can reach the internet (a proxy or a security program can block it; if so, ask whoever looks after this computer).`npip said:`n$(Get-LastLine $installed.Output)" + Exit-WithGuide + } + [IO.File]::WriteAllText($Stamp, "$want`n", [Text.UTF8Encoding]::new($false)) +} +Write-Line ('OK venv - {0}' -f $Venv) + +Write-ToolsFile $true +Write-Line '' +Write-Line 'Preflight passed. Tool paths are recorded in .wikitool-tools.json; tools/wikitool is ready.' +exit 0 diff --git a/tools/wikitool b/tools/wikitool index 383dd00..7217e9f 100755 --- a/tools/wikitool +++ b/tools/wikitool @@ -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 diff --git a/tools/wikitool.ps1 b/tools/wikitool.ps1 new file mode 100644 index 0000000..d0bc48a --- /dev/null +++ b/tools/wikitool.ps1 @@ -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 [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