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

+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