feat: PowerShell 7 preflight and launcher - preflight.ps1, wikitool.ps1, doctor policy and Mark of the Web checks, pwsh CI job (#151, B)
Files changed: - .gitea/workflows/ci.yml - CHANGES.md - INSTALL.md - README.md - VERSION - docs/why-gates-are-code.md - instructions/bootstrap.md - instructions/bug-report.md - instructions/preflight.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/bugreport.py - tools/chemenu/cli.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/doctor.py - tools/chemenu/prerequisites.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_bugreport.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/chemenu/toolpaths.py - tools/preflight.ps1 - tools/wikitool - tools/wikitool.ps1
This commit is contained in:
1 parent
4035b1ba12
commit
210e0c8286
27 files changed
+1513
-32
No files matched your search
+4
-1
@@ -61,6 +61,8 @@ from the repo root:
|
||||
tools/preflight.sh
|
||||
```
|
||||
|
||||
From PowerShell 7 on Windows, the twin: `pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1`.
|
||||
|
||||
Until it has passed, every `tools/wikitool` call exits 42 and names it.
|
||||
|
||||
## Usage
|
||||
@@ -3221,8 +3223,9 @@ Check that this instance is correctly configured.
|
||||
**NOTES**
|
||||
|
||||
- Checks dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, the kb/raw/reports/work/instructions structure, and generated files.
|
||||
- Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that finished, every tool `tools/prerequisites.txt` names for this platform recorded, and every recorded path still there - a `FAIL` otherwise, fixed by running `tools/preflight.sh` again.
|
||||
- Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that finished, every tool `tools/prerequisites.txt` names for this platform recorded, and every recorded path still there - a `FAIL` otherwise, fixed by running `tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).
|
||||
- Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 characters) - the limit the preflight enforces before it sets anything up.
|
||||
- PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether a group policy sets it, which only whoever administers the computer can change - and a `FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus `tar` do not. `tools/preflight.ps1` checks the same two things before it stops.
|
||||
- Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`.
|
||||
- KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three.
|
||||
- Environment note: `ENVIRONMENT.md` is optional, so absent is `OK`; a still-templated one is a `WARN`.
|
||||
|
||||
@@ -21,6 +21,10 @@ file deliberately has none - and `docs verify` now enforces that.
|
||||
tools/preflight.sh # from the repo root
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 # PowerShell 7 on Windows
|
||||
```
|
||||
|
||||
The preflight is the one way a checkout gets its environment: it checks the
|
||||
tools listed in `prerequisites.txt`, records their absolute paths in
|
||||
`../.wikitool-tools.json`, creates `.venv` and installs `requirements.txt` into
|
||||
@@ -45,8 +49,10 @@ fix rather than degrading silently.
|
||||
```
|
||||
tools/
|
||||
wikitool entry point (POSIX sh): stops with exit 42 until the preflight has passed
|
||||
wikitool.ps1 the same entry point for PowerShell 7, which resolves `tools/wikitool` to this file first
|
||||
run_wikitool.py what the launcher runs with the venv's Python - puts chemenu on sys.path without PYTHONPATH
|
||||
preflight.sh checks prerequisites.txt, records .wikitool-tools.json, creates .venv (POSIX sh)
|
||||
preflight.ps1 the same for PowerShell 7; also checks the execution policy and the Mark of the Web
|
||||
prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor`
|
||||
trace-hook what the harness hooks call: trace_ingest.py under the venv's Python
|
||||
chemenu/
|
||||
|
||||
+3
-1
@@ -1003,7 +1003,9 @@ def launcher_command(root: Path):
|
||||
ps1, sh = tools / "wikitool.ps1", tools / "wikitool"
|
||||
pwsh = shutil.which("pwsh")
|
||||
if ps1.is_file() and pwsh:
|
||||
return [pwsh, "-NoProfile", "-File", str(ps1)]
|
||||
# Bypass: a policy that blocks the script is a finding for `doctor`'s
|
||||
# execution-policy check, and must not also stop the report from running.
|
||||
return [pwsh, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(ps1)]
|
||||
bash = shutil.which("bash")
|
||||
if sh.is_file() and bash:
|
||||
return [bash, str(sh)]
|
||||
|
||||
@@ -63,6 +63,8 @@ except ModuleNotFoundError as exc:
|
||||
"This is not optional - schema validation depends on it. Run the preflight,\n"
|
||||
"which (re)installs tools/.venv from tools/requirements.txt:\n"
|
||||
" tools/preflight.sh\n"
|
||||
"or, from PowerShell 7:\n"
|
||||
f" {toolpaths.PREFLIGHT_PWSH}\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -1516,7 +1516,7 @@ def run_upgrade(
|
||||
summary += (
|
||||
" Nothing was committed and nothing is verified yet."
|
||||
" `instructions/upgrade-instance.md` carries the order for everything that follows"
|
||||
" and resumes with the preflight (`tools/preflight.sh`), which `tools/wikitool` now"
|
||||
" and resumes with the preflight (`tools/preflight.sh`, or `tools/preflight.ps1` under PowerShell 7), which `tools/wikitool` now"
|
||||
" refuses to run without."
|
||||
)
|
||||
success(summary)
|
||||
@@ -49,7 +49,7 @@ def _git(args: list[str]) -> Optional[subprocess.CompletedProcess]:
|
||||
return None
|
||||
|
||||
|
||||
PREFLIGHT_FIX = "Run tools/preflight.sh"
|
||||
PREFLIGHT_FIX = f"Run tools/preflight.sh (PowerShell 7: {toolpaths.PREFLIGHT_PWSH})"
|
||||
|
||||
|
||||
def check_python() -> Check:
|
||||
@@ -130,6 +130,53 @@ def check_install_dir() -> Check:
|
||||
)
|
||||
|
||||
|
||||
def check_execution_policy() -> Check:
|
||||
"""Whether an ordinary PowerShell 7 may run `tools/wikitool.ps1` - the policy
|
||||
the preflight checks before it stops, so a harness that starts `pwsh`
|
||||
without a bypass is not turned away by a setting nobody looked at."""
|
||||
if prerequisites.platform() != "windows":
|
||||
return Check("execution-policy", "OK", "only PowerShell on Windows has one - not applicable here")
|
||||
policy = prerequisites.execution_policy()
|
||||
if policy is None:
|
||||
return Check(
|
||||
"execution-policy", "WARN", "the PowerShell execution policy could not be read",
|
||||
f"{PREFLIGHT_FIX}; the preflight checks it",
|
||||
)
|
||||
if not policy.blocks:
|
||||
return Check("execution-policy", "OK", policy.describe())
|
||||
if policy.group_policy:
|
||||
return Check(
|
||||
"execution-policy", "FAIL",
|
||||
f"a group policy sets the PowerShell execution policy to {policy.policy} - "
|
||||
"tools/wikitool.ps1 does not start",
|
||||
"A group policy cannot be overridden from this computer: ask whoever looks after it to allow "
|
||||
"locally written scripts (RemoteSigned) for PowerShell 7, or run `tools/wikitool` from Git Bash",
|
||||
)
|
||||
return Check(
|
||||
"execution-policy", "FAIL",
|
||||
f"the PowerShell execution policy is {policy.describe()} - tools/wikitool.ps1 does not start",
|
||||
"In a PowerShell 7 window: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned",
|
||||
)
|
||||
|
||||
|
||||
def check_script_marks() -> Check:
|
||||
"""Mark of the Web on the stack's PowerShell scripts: a wiki downloaded with a
|
||||
browser and unpacked in Explorer carries one, and PowerShell refuses to run
|
||||
a marked script under its usual policy."""
|
||||
if prerequisites.platform() != "windows":
|
||||
return Check("script-marks", "OK", "no Mark of the Web outside Windows")
|
||||
marked = prerequisites.marked_scripts()
|
||||
if not marked:
|
||||
return Check("script-marks", "OK", "no script under tools/ is marked as downloaded")
|
||||
shown = ", ".join(marked[:5]) + (", ..." if len(marked) > 5 else "")
|
||||
return Check(
|
||||
"script-marks", "FAIL",
|
||||
f"Windows marks scripts under tools/ as downloaded from the internet: {shown}",
|
||||
"In a PowerShell 7 window, from the folder holding tools/: "
|
||||
"Get-ChildItem -Recurse -File | Unblock-File",
|
||||
)
|
||||
|
||||
|
||||
def check_author() -> Check:
|
||||
try:
|
||||
author = config.default_author()
|
||||
@@ -669,6 +716,8 @@ def run_doctor() -> list[Check]:
|
||||
check_tool_paths(),
|
||||
check_ripgrep(),
|
||||
check_install_dir(),
|
||||
check_execution_policy(),
|
||||
check_script_marks(),
|
||||
check_author(),
|
||||
check_stack_version(),
|
||||
check_kb_version(),
|
||||
@@ -706,10 +755,16 @@ def run_doctor() -> list[Check]:
|
||||
"Tool paths (`tool-paths`): `.wikitool-tools.json` written by a preflight that "
|
||||
"finished, every tool `tools/prerequisites.txt` names for this platform recorded, and "
|
||||
"every recorded path still there - a `FAIL` otherwise, fixed by running "
|
||||
"`tools/preflight.sh` again.",
|
||||
"`tools/preflight.sh` again (PowerShell 7: `tools/preflight.ps1`).",
|
||||
"Install folder (`install-dir`): on Windows with long paths off, a `FAIL` when the "
|
||||
"folder holding `tools/` is longer than `tools/prerequisites.txt` allows (95 "
|
||||
"characters) - the limit the preflight enforces before it sets anything up.",
|
||||
"PowerShell (`execution-policy`, `script-marks`; Windows only, `OK` elsewhere): a `FAIL` "
|
||||
"when the effective execution policy is `Restricted` or `AllSigned` - the line then says whether "
|
||||
"a group policy sets it, which only whoever administers the computer can change - and a "
|
||||
"`FAIL` when a script under `tools/` carries a Mark of the Web from the internet zone, "
|
||||
"which a browser download unpacked in Explorer leaves behind and `Invoke-WebRequest` plus "
|
||||
"`tar` do not. `tools/preflight.ps1` checks the same two things before it stops.",
|
||||
"Personalization: `USER.md`/`SOUL.md` present **and** filled - a file still carrying "
|
||||
"the template's sentinel is a `FAIL`.",
|
||||
"KB conventions: `kb/CONVENTIONS.md` present, unsentinelled, and naming all three "
|
||||
|
||||
@@ -8,21 +8,34 @@ preflight enforced up front - a tool whose recorded path has gone, a checkout
|
||||
moved into a folder too long for Windows.
|
||||
|
||||
`CHEMENU_PREFLIGHT_PLATFORM` and `CHEMENU_PREFLIGHT_LONGPATHS` stand in for the
|
||||
real platform and registry, exactly as they do for the preflight scripts; the
|
||||
test suite is their only user.
|
||||
real platform and registry, exactly as they do for the preflight scripts;
|
||||
`CHEMENU_PREFLIGHT_POLICY` and `CHEMENU_PREFLIGHT_MARKED` stand in for the
|
||||
PowerShell execution policy and the Mark of the Web on the stack's scripts the
|
||||
same way (what `tools/preflight.ps1` documents). The test suite is their only
|
||||
user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from chemenu import config, titles
|
||||
from chemenu import config, titles, toolpaths
|
||||
|
||||
ENV_PLATFORM = "CHEMENU_PREFLIGHT_PLATFORM"
|
||||
ENV_LONGPATHS = "CHEMENU_PREFLIGHT_LONGPATHS"
|
||||
ENV_POLICY = "CHEMENU_PREFLIGHT_POLICY"
|
||||
ENV_MARKED = "CHEMENU_PREFLIGHT_MARKED"
|
||||
|
||||
# Which scopes decide whether an ordinary pwsh starts tools/wikitool.ps1, strongest first.
|
||||
# `Process` is left out: it holds a bypass the caller started with, not the machine's setting.
|
||||
POLICY_SCOPES = ("MachinePolicy", "UserPolicy", "CurrentUser", "LocalMachine")
|
||||
GROUP_POLICY_SCOPES = ("MachinePolicy", "UserPolicy")
|
||||
BLOCKING_POLICIES = ("Restricted", "AllSigned")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -115,3 +128,86 @@ def install_dir_problem(folder: Optional[str] = None) -> Optional[str]:
|
||||
f"the install folder is {length} characters long and Windows allows at most "
|
||||
f"{limit} here (long paths are off): {folder}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Policy:
|
||||
"""The execution policy an ordinary pwsh applies. `scope` is None when no
|
||||
scope sets one, which on Windows means the default, RemoteSigned."""
|
||||
|
||||
scope: Optional[str]
|
||||
policy: str
|
||||
|
||||
@property
|
||||
def blocks(self) -> bool:
|
||||
return self.policy in BLOCKING_POLICIES
|
||||
|
||||
@property
|
||||
def group_policy(self) -> bool:
|
||||
return self.scope in GROUP_POLICY_SCOPES
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.scope is None:
|
||||
return f"{self.policy} (the default)"
|
||||
return f"{self.policy} (set for {self.scope})"
|
||||
|
||||
|
||||
def _parse_policy_list(text: str) -> Optional[Policy]:
|
||||
"""`Scope=Policy` pairs, separated by commas or lines, to the effective policy."""
|
||||
pairs = {}
|
||||
for item in re.split(r"[,\r\n]+", text):
|
||||
scope, sep, policy = item.partition("=")
|
||||
if sep:
|
||||
pairs[scope.strip()] = policy.strip()
|
||||
if not pairs:
|
||||
return None
|
||||
for scope in POLICY_SCOPES:
|
||||
policy = pairs.get(scope, "Undefined")
|
||||
if policy and policy != "Undefined":
|
||||
return Policy(scope, policy)
|
||||
return Policy(None, "RemoteSigned")
|
||||
|
||||
|
||||
def execution_policy() -> Optional[Policy]:
|
||||
"""The effective PowerShell execution policy, or None when it cannot be
|
||||
read here (not Windows, or no pwsh to ask)."""
|
||||
forced = os.environ.get(ENV_POLICY, "").strip()
|
||||
if forced:
|
||||
return _parse_policy_list(forced)
|
||||
if platform() != "windows" or sys.platform != "win32":
|
||||
return None
|
||||
try: # pragma: no cover - Windows only
|
||||
pwsh = toolpaths.resolve("pwsh")
|
||||
done = subprocess.run(
|
||||
[pwsh, "-NoProfile", "-NonInteractive", "-Command",
|
||||
"Get-ExecutionPolicy -List | ForEach-Object { '{0}={1}' -f $_.Scope, $_.ExecutionPolicy }"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (toolpaths.ToolPathError, OSError, subprocess.SubprocessError): # pragma: no cover
|
||||
return None
|
||||
if done.returncode != 0: # pragma: no cover - Windows only
|
||||
return None
|
||||
return _parse_policy_list(done.stdout) # pragma: no cover - Windows only
|
||||
|
||||
|
||||
def marked_scripts() -> list[str]:
|
||||
"""PowerShell scripts below `tools/` that carry a Mark of the Web from the
|
||||
internet zone (3 or 4), as names relative to `tools/`. A browser download
|
||||
unpacked in Explorer has them; `Invoke-WebRequest` and `tar` do not."""
|
||||
forced = os.environ.get(ENV_MARKED, "").strip()
|
||||
if forced:
|
||||
return [name.strip() for name in forced.split(",") if name.strip()]
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
tools_dir = config._PACKAGE_ROOT / "tools" # pragma: no cover - Windows only
|
||||
marked = [] # pragma: no cover - Windows only
|
||||
for script in sorted(tools_dir.rglob("*.ps1")): # pragma: no cover - Windows only
|
||||
if ".venv" in script.relative_to(tools_dir).parts:
|
||||
continue
|
||||
try:
|
||||
stream = Path(str(script) + ":Zone.Identifier").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(r"ZoneId=[3-9]", stream):
|
||||
marked.append(script.relative_to(tools_dir).as_posix())
|
||||
return marked # pragma: no cover - Windows only
|
||||
@@ -35,6 +35,8 @@ _WIKITOOL_ENV = (
|
||||
"WIKITOOL_TASKS_CONFIG",
|
||||
"CHEMENU_PREFLIGHT_PLATFORM",
|
||||
"CHEMENU_PREFLIGHT_LONGPATHS",
|
||||
"CHEMENU_PREFLIGHT_POLICY",
|
||||
"CHEMENU_PREFLIGHT_MARKED",
|
||||
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||
|
||||
# Environment git reads for identity or for where its repo lives. A stray
|
||||
|
||||
@@ -61,7 +61,7 @@ def checkout(tmp_path: Path, monkeypatch) -> Path:
|
||||
json.dumps({"schema": 1, "api_token": TOKEN, "enabled": True})
|
||||
)
|
||||
(root / ".wikitool-tools.json").write_text(
|
||||
json.dumps({"schema": 1, "tools": {"python": {"path": "/definitely/not/here/python"}}})
|
||||
json.dumps({"schema": 1, "complete": True, "tools": {"python": "/definitely/not/here/python"}})
|
||||
)
|
||||
launcher = root / "tools" / "wikitool"
|
||||
launcher.write_text(LAUNCHER.format(python=sys.executable, title=TITLE))
|
||||
@@ -271,8 +271,9 @@ def test_the_tools_config_path_check_reports_what_is_missing(checkout, tmp_path)
|
||||
bundle = collect(checkout, tmp_path, "--no-trace")
|
||||
config = json.loads((bundle / "environment.json").read_text())["tools_config"]
|
||||
assert config["status"] == "present"
|
||||
assert config["content"]["complete"] is True
|
||||
assert config["path_checks"] == [
|
||||
{"at": "tools.python.path", "path": "/definitely/not/here/python", "exists": False}
|
||||
{"at": "tools.python", "path": "/definitely/not/here/python", "exists": False}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ def _detail(checks, name) -> str:
|
||||
return next(c.detail for c in checks if c.name == name)
|
||||
|
||||
|
||||
def _fix(checks, name) -> str:
|
||||
return next(c.fix or "" for c in checks if c.name == name)
|
||||
|
||||
|
||||
def test_healthy_instance_has_no_fail(instance):
|
||||
checks = doctor.run_doctor()
|
||||
assert not any(c.status == "FAIL" for c in checks)
|
||||
@@ -640,3 +644,70 @@ def test_install_dir_is_not_limited_off_windows(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setattr(prerequisites, "install_dir", lambda: "/" + "x" * 300)
|
||||
assert _status(doctor.run_doctor(), "install-dir") == "OK"
|
||||
|
||||
|
||||
# --- PowerShell: execution policy and Mark of the Web (Gitea #151) -----------------
|
||||
|
||||
OPEN_POLICY = "MachinePolicy=Undefined,UserPolicy=Undefined,Process=Undefined,CurrentUser=Undefined,LocalMachine=RemoteSigned"
|
||||
|
||||
|
||||
def _windows(monkeypatch, policy=None, marked=None):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
if policy is not None:
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, policy)
|
||||
if marked is not None:
|
||||
monkeypatch.setenv(prerequisites.ENV_MARKED, marked)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("check", ["execution-policy", "script-marks"])
|
||||
def test_powershell_checks_do_not_apply_off_windows(instance, monkeypatch, check):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, "CurrentUser=AllSigned")
|
||||
monkeypatch.setenv(prerequisites.ENV_MARKED, "wikitool.ps1")
|
||||
assert _status(doctor.run_doctor(), check) == "OK"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy, expected", [
|
||||
(OPEN_POLICY, "OK"),
|
||||
("CurrentUser=Unrestricted,LocalMachine=Restricted", "OK"),
|
||||
("Process=Restricted,LocalMachine=RemoteSigned", "OK"),
|
||||
("CurrentUser=Undefined,LocalMachine=Undefined", "OK"),
|
||||
("CurrentUser=Restricted,LocalMachine=RemoteSigned", "FAIL"),
|
||||
("LocalMachine=AllSigned", "FAIL"),
|
||||
])
|
||||
def test_execution_policy_blocks_only_restricted_and_allsigned(instance, monkeypatch, policy, expected):
|
||||
_windows(monkeypatch, policy=policy)
|
||||
assert _status(doctor.run_doctor(), "execution-policy") == expected
|
||||
|
||||
|
||||
def test_execution_policy_fix_names_the_one_line_command(instance, monkeypatch):
|
||||
_windows(monkeypatch, policy="CurrentUser=Restricted")
|
||||
fix = _fix(doctor.run_doctor(), "execution-policy")
|
||||
assert "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned" in fix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["MachinePolicy", "UserPolicy"])
|
||||
def test_a_group_policy_gets_no_command_it_could_not_obey(instance, monkeypatch, scope):
|
||||
_windows(monkeypatch, policy=f"{scope}=AllSigned,LocalMachine=RemoteSigned")
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "execution-policy") == "FAIL"
|
||||
assert "Set-ExecutionPolicy" not in _fix(checks, "execution-policy")
|
||||
assert "Git Bash" in _fix(checks, "execution-policy")
|
||||
|
||||
|
||||
def test_execution_policy_that_cannot_be_read_is_a_warning(instance, monkeypatch):
|
||||
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
|
||||
assert _status(doctor.run_doctor(), "execution-policy") == "WARN"
|
||||
|
||||
|
||||
def test_marked_scripts_fail_with_the_unblock_command(instance, monkeypatch):
|
||||
_windows(monkeypatch, marked="wikitool.ps1,preflight.ps1")
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "script-marks") == "FAIL"
|
||||
assert "wikitool.ps1" in _detail(checks, "script-marks")
|
||||
assert "Unblock-File" in _fix(checks, "script-marks")
|
||||
|
||||
|
||||
def test_unmarked_scripts_are_ok(instance, monkeypatch):
|
||||
_windows(monkeypatch)
|
||||
assert _status(doctor.run_doctor(), "script-marks") == "OK"
|
||||
@@ -94,7 +94,8 @@ class Machine:
|
||||
self.root = base / root_name
|
||||
self.tools = self.root / "tools"
|
||||
self.tools.mkdir(parents=True)
|
||||
for name in ("preflight.sh", "prerequisites.txt", "wikitool", "run_wikitool.py", "trace-hook"):
|
||||
for name in ("preflight.sh", "preflight.ps1", "prerequisites.txt", "wikitool", "wikitool.ps1",
|
||||
"run_wikitool.py", "trace-hook"):
|
||||
shutil.copy2(TOOLS / name, self.tools / name)
|
||||
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
|
||||
self.sysbin = base / "sysbin"
|
||||
@@ -392,8 +393,11 @@ def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout)
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_there_is_a_powershell_launcher_slot_but_no_cmd():
|
||||
def test_there_is_a_powershell_launcher_and_no_cmd():
|
||||
"""pwsh resolves `tools/wikitool` to `wikitool.ps1` before the sh launcher, and
|
||||
cmd.exe is not a supported shell, so there is no `.cmd`."""
|
||||
assert (TOOLS / "wikitool").is_file()
|
||||
assert (TOOLS / "wikitool.ps1").is_file()
|
||||
assert not (TOOLS / "wikitool.cmd").exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""tools/preflight.ps1 and tools/wikitool.ps1 (Gitea #151, section B).
|
||||
|
||||
The PowerShell twin of `test_preflight.py`, run against the same stub machine: a
|
||||
`PATH` the test builds, stub tools in it, and a fake Python that answers the probe
|
||||
and fakes `-m venv`/`-m pip`. The scripts need PowerShell 7, so these tests skip
|
||||
where there is none and run in CI's `pwsh` job, whose image carries it; everything
|
||||
Windows-specific (policy, Mark of the Web, path limit, Store alias) is driven by the
|
||||
same kind of environment hook the shell script has.
|
||||
|
||||
What these add to the shell tests is the one thing only the twin can break: the two
|
||||
scripts must record the same file. `test_both_preflights_record_the_same_file`
|
||||
compares them byte for byte.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import prerequisites
|
||||
from chemenu.tests.test_preflight import (
|
||||
ECHO_PYTHON, SHELLS, TOOLS, Machine, _machine_with_root_of, assert_guidance,
|
||||
)
|
||||
|
||||
PWSH = shutil.which("pwsh")
|
||||
|
||||
pytestmark = pytest.mark.skipif(PWSH is None, reason="PowerShell 7 (pwsh) is not installed")
|
||||
|
||||
WINDOWS = {"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"}
|
||||
|
||||
|
||||
def _pwsh(machine: Machine, script: str, *args: str) -> subprocess.CompletedProcess:
|
||||
env = {
|
||||
"PATH": os.pathsep.join(str(d) for d in machine.path_dirs),
|
||||
"HOME": str(machine.base),
|
||||
"PIP_LOG": str(machine.pip_log),
|
||||
"DOTNET_CLI_TELEMETRY_OPTOUT": "1",
|
||||
"POWERSHELL_TELEMETRY_OPTOUT": "1",
|
||||
**machine.extra_env,
|
||||
}
|
||||
return subprocess.run(
|
||||
[PWSH, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(machine.tools / script), *args],
|
||||
capture_output=True, text=True, env=env, timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def _preflight(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
return _pwsh(machine, "preflight.ps1", *args)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine(tmp_path: Path) -> Machine:
|
||||
return Machine(tmp_path.resolve()).standard()
|
||||
|
||||
|
||||
# --- the happy path ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine):
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = machine.recorded()
|
||||
assert data["schema"] == 1 and data["complete"] is True
|
||||
assert set(data["tools"]) == {"python", "git", "rg"}
|
||||
assert data["tools"]["rg"] == str(machine.stubs / "rg")
|
||||
assert (machine.tools / ".venv" / "bin" / "python").is_file()
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
assert "Preflight passed" in result.stdout
|
||||
|
||||
|
||||
def test_second_run_changes_nothing(machine):
|
||||
assert _preflight(machine).returncode == 0
|
||||
before = machine.tools_file.read_text(encoding="utf-8")
|
||||
again = _preflight(machine)
|
||||
assert again.returncode == 0, again.stdout
|
||||
assert machine.tools_file.read_text(encoding="utf-8") == before
|
||||
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
|
||||
def test_both_preflights_record_the_same_file(machine):
|
||||
assert _preflight(machine).returncode == 0
|
||||
from_pwsh = machine.tools_file.read_bytes()
|
||||
machine.tools_file.unlink()
|
||||
assert machine.run(shell=SHELLS[0]).returncode == 0
|
||||
assert machine.tools_file.read_bytes() == from_pwsh
|
||||
|
||||
|
||||
def test_help_prints_the_usage_and_exits_0(machine):
|
||||
result = _preflight(machine, "--help")
|
||||
assert result.returncode == 0
|
||||
assert "--set" in result.stdout
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
# --- stop cases -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_rg_stops_with_42_and_a_missing_line(tmp_path):
|
||||
machine = Machine(tmp_path.resolve()).standard(rg=False)
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert any(line.split()[:2] == ["MISSING", "rg"] for line in result.stdout.splitlines())
|
||||
assert_guidance(result.stdout, "ripgrep (rg) was not found", "--set rg=")
|
||||
data = machine.recorded()
|
||||
assert data["complete"] is False and "rg" not in data["tools"]
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
def test_python_too_old_stops(machine):
|
||||
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert "TOO_OLD" in result.stdout
|
||||
assert_guidance(result.stdout, "Python 3.9 is too old")
|
||||
|
||||
|
||||
def test_invalid_set_path_writes_nothing(machine):
|
||||
result = _preflight(machine, "--set", f"rg={machine.base / 'nowhere' / 'rg'}")
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
def test_valid_set_path_is_recorded_and_kept(machine):
|
||||
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
|
||||
machine.stub("rg", "#!/bin/sh\nexit 1\n")
|
||||
result = _preflight(machine, f"--set=rg={elsewhere}")
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
assert _preflight(machine).returncode == 0
|
||||
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
|
||||
|
||||
|
||||
def test_set_for_an_unknown_tool_is_a_usage_error(machine):
|
||||
result = _preflight(machine, "--set", "foo=/bin/sh")
|
||||
assert result.returncode == 1
|
||||
assert not machine.tools_file.exists()
|
||||
|
||||
|
||||
def test_venv_that_cannot_be_created_stops(machine):
|
||||
machine.extra_env["FAKE_VENV_FAIL"] = "1"
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "ensurepip is not available")
|
||||
|
||||
|
||||
def test_pip_failure_stops_and_is_retried_next_time(machine):
|
||||
machine.extra_env["FAKE_PIP_FAIL"] = "1"
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "network unreachable")
|
||||
assert machine.recorded()["complete"] is False
|
||||
del machine.extra_env["FAKE_PIP_FAIL"]
|
||||
assert _preflight(machine).returncode == 0
|
||||
assert machine.recorded()["complete"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
|
||||
def test_store_alias_is_never_run_and_never_recorded(machine, platform, alias):
|
||||
marker = machine.base / "alias-was-run"
|
||||
apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps"
|
||||
machine.stub(alias, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps)
|
||||
machine.path_dirs.insert(0, apps)
|
||||
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
|
||||
if platform == "windows":
|
||||
machine.python("python")
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 0, result.stdout
|
||||
recorded = machine.recorded()["tools"]["python"]
|
||||
assert "WindowsApps" not in recorded
|
||||
assert recorded.startswith(str(machine.stubs))
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
# --- install folder length (D32) --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length, longpaths, expected", [
|
||||
(95, "0", 0),
|
||||
(96, "0", 42),
|
||||
(96, "1", 0),
|
||||
])
|
||||
def test_install_folder_limit_at_the_boundary(tmp_path, length, longpaths, expected):
|
||||
machine = _machine_with_root_of(tmp_path, length)
|
||||
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == expected, result.stdout
|
||||
if expected == 42:
|
||||
assert_guidance(result.stdout, f"too long ({length} characters, at most 95)", "C:\\Chemenu")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
# --- execution policy and Mark of the Web (D16, T6) --------------------------------
|
||||
|
||||
OPEN = "MachinePolicy=Undefined,UserPolicy=Undefined,Process=Undefined,CurrentUser=Undefined,LocalMachine=RemoteSigned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", [
|
||||
OPEN,
|
||||
"CurrentUser=Unrestricted,LocalMachine=Restricted",
|
||||
"Process=Restricted,LocalMachine=RemoteSigned",
|
||||
"CurrentUser=Undefined,LocalMachine=Undefined",
|
||||
])
|
||||
def test_a_policy_that_lets_scripts_run_passes(machine, policy):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": policy})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 0, result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", [
|
||||
"CurrentUser=Restricted,LocalMachine=RemoteSigned",
|
||||
"CurrentUser=AllSigned",
|
||||
"CurrentUser=Undefined,LocalMachine=Restricted",
|
||||
"Process=Bypass,LocalMachine=AllSigned",
|
||||
])
|
||||
def test_a_policy_that_blocks_scripts_stops_with_the_one_line_fix(machine, policy):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": policy})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", ["MachinePolicy", "UserPolicy"])
|
||||
def test_a_group_policy_is_a_stop_that_names_the_administrator(machine, scope):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_POLICY": f"{scope}=AllSigned,LocalMachine=RemoteSigned"})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "group policy", "Git Bash")
|
||||
assert "Set-ExecutionPolicy" not in result.stdout
|
||||
|
||||
|
||||
def test_policy_and_marks_are_not_checked_off_windows(machine):
|
||||
machine.extra_env.update({
|
||||
"CHEMENU_PREFLIGHT_PLATFORM": "linux",
|
||||
"CHEMENU_PREFLIGHT_POLICY": "CurrentUser=AllSigned",
|
||||
"CHEMENU_PREFLIGHT_MARKED": "preflight.ps1",
|
||||
})
|
||||
assert _preflight(machine).returncode == 0
|
||||
|
||||
|
||||
def test_a_marked_script_stops_with_the_unblock_command(machine):
|
||||
machine.extra_env.update({**WINDOWS, "CHEMENU_PREFLIGHT_MARKED": "wikitool.ps1,preflight.ps1"})
|
||||
result = _preflight(machine)
|
||||
assert result.returncode == 42
|
||||
assert_guidance(result.stdout, "wikitool.ps1", "Unblock-File")
|
||||
assert not (machine.tools / ".venv").exists()
|
||||
|
||||
|
||||
# --- the launcher -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
|
||||
return _pwsh(machine, "wikitool.ps1", *args)
|
||||
|
||||
|
||||
def _complete_file(machine: Machine, complete: bool = True) -> None:
|
||||
machine.tools_file.write_text(json.dumps(
|
||||
{"schema": 1, "complete": complete, "tools": {"git": "/usr/bin/git"}}, indent=2),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["no file", "no venv", "incomplete"])
|
||||
def test_launcher_stops_with_42_until_the_preflight_has_passed(machine, state):
|
||||
if state != "no file":
|
||||
_complete_file(machine, complete=(state != "incomplete"))
|
||||
if state != "no venv":
|
||||
machine.stub("python", ECHO_PYTHON, machine.tools / ".venv" / "bin")
|
||||
result = _launch(machine, "doctor")
|
||||
assert result.returncode == 42
|
||||
assert "tools/preflight.ps1" in result.stderr
|
||||
assert "ExecutionPolicy Bypass" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
|
||||
def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout):
|
||||
_complete_file(machine)
|
||||
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
|
||||
result = _launch(machine, "doctor", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
|
||||
|
||||
|
||||
def test_launcher_passes_the_exit_code_of_the_cli_through(machine):
|
||||
_complete_file(machine)
|
||||
machine.stub("python", "#!/bin/sh\nexit 7\n", machine.tools / ".venv" / "bin")
|
||||
assert _launch(machine, "lint").returncode == 7
|
||||
|
||||
|
||||
def test_both_launchers_exist_for_pwsh_to_resolve():
|
||||
assert (TOOLS / "wikitool.ps1").is_file() and (TOOLS / "wikitool").is_file()
|
||||
|
||||
|
||||
def test_the_python_side_reads_what_the_hooks_say(monkeypatch):
|
||||
"""The doctor checks use the same hook values the script does; the two
|
||||
parsers must agree on what a policy list means."""
|
||||
monkeypatch.setenv(prerequisites.ENV_POLICY, "MachinePolicy=AllSigned,LocalMachine=RemoteSigned")
|
||||
policy = prerequisites.execution_policy()
|
||||
assert policy is not None and policy.blocks and policy.group_policy
|
||||
@@ -31,7 +31,8 @@ from chemenu.errors import ChemenuError
|
||||
|
||||
FILE_NAME = ".wikitool-tools.json"
|
||||
SCHEMA = 1
|
||||
PREFLIGHT = "run the preflight again: tools/preflight.sh"
|
||||
PREFLIGHT_PWSH = "pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1"
|
||||
PREFLIGHT = f"run the preflight again: tools/preflight.sh (PowerShell 7: {PREFLIGHT_PWSH})"
|
||||
|
||||
|
||||
class ToolPathError(ChemenuError):
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
#Requires -Version 7
|
||||
# Preflight: check that this machine has what the stack needs, record where each
|
||||
# tool lives, and set up tools/.venv - before any `wikitool` command can run.
|
||||
#
|
||||
# pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
# pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 --set rg=C:\Tools\rg.exe
|
||||
#
|
||||
# Always start it that way (instructions/preflight.md): the bypass holds for this one
|
||||
# process only and changes no setting, and it is what lets a script that carries a
|
||||
# Mark of the Web start at all, so that it can report its own mark.
|
||||
#
|
||||
# Exit 0: everything is in place and .wikitool-tools.json is complete.
|
||||
# Exit 42: the user has to act. The output says what, why, the command that fixes
|
||||
# it and what happens next; show it verbatim and wait (AGENTS.md, Tool
|
||||
# error contract). Never install anything on the user's behalf.
|
||||
# Exit 1: this script was called wrongly, or the tree next to it is incomplete.
|
||||
#
|
||||
# The counterpart of tools/preflight.sh, and the two answer the same questions from the
|
||||
# same list, tools/prerequisites.txt. What only this one checks: the PowerShell execution
|
||||
# policy and a Mark of the Web on the stack's own scripts - the two things that stop
|
||||
# tools/wikitool.ps1 from starting, and that exist only here. Windows PowerShell 5.1 is
|
||||
# not supported; `#Requires` turns it away.
|
||||
#
|
||||
# Four variables exist for the test suite and are read nowhere else:
|
||||
# CHEMENU_PREFLIGHT_PLATFORM (windows|macos|linux), CHEMENU_PREFLIGHT_LONGPATHS (0|1),
|
||||
# CHEMENU_PREFLIGHT_POLICY (`Scope=Policy,...`, as `Get-ExecutionPolicy -List` names them)
|
||||
# and CHEMENU_PREFLIGHT_MARKED (comma-separated script names below tools/) stand in for
|
||||
# the operating system, the registry, the policy and the file streams.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandArgumentPassing = 'Standard'
|
||||
|
||||
$Dir = $PSScriptRoot
|
||||
$Root = Split-Path -Parent $Dir
|
||||
$Manifest = Join-Path $Dir 'prerequisites.txt'
|
||||
$ToolsFile = Join-Path $Root '.wikitool-tools.json'
|
||||
$Venv = Join-Path $Dir '.venv'
|
||||
$Requirements = Join-Path $Dir 'requirements.txt'
|
||||
$Stamp = Join-Path $Venv '.chemenu-requirements.sha256'
|
||||
$Self = 'pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1'
|
||||
|
||||
$PyProbe = "import sys; print(str(sys.version_info[0]) + '.' + str(sys.version_info[1])); print(sys.executable)"
|
||||
|
||||
function Write-Line {
|
||||
param([string]$Text = '')
|
||||
[Console]::Out.WriteLine($Text)
|
||||
}
|
||||
|
||||
function Write-ErrorLine {
|
||||
param([string]$Text)
|
||||
[Console]::Error.WriteLine($Text)
|
||||
}
|
||||
|
||||
# --- arguments ----------------------------------------------------------------
|
||||
|
||||
$Sets = @{}
|
||||
$index = 0
|
||||
while ($index -lt $args.Count) {
|
||||
$arg = [string]$args[$index]
|
||||
$given = $null
|
||||
if ($arg -eq '--set') {
|
||||
if ($index + 1 -ge $args.Count) {
|
||||
Write-ErrorLine 'preflight: --set needs <tool>=<path>'
|
||||
exit 1
|
||||
}
|
||||
$index++
|
||||
$given = [string]$args[$index]
|
||||
} elseif ($arg.StartsWith('--set=')) {
|
||||
$given = $arg.Substring(6)
|
||||
} elseif ($arg -eq '-h' -or $arg -eq '--help') {
|
||||
Write-Line "usage: $Self [--set <tool>=<path>]..."
|
||||
Write-Line ''
|
||||
Write-Line 'Checks the tools this stack needs (tools/prerequisites.txt), records their paths'
|
||||
Write-Line 'in .wikitool-tools.json and sets up tools/.venv. Exit 0 means ready; exit 42'
|
||||
Write-Line 'means the user has to act - the output says how.'
|
||||
exit 0
|
||||
} else {
|
||||
Write-ErrorLine "preflight: unknown argument: $arg"
|
||||
exit 1
|
||||
}
|
||||
$pivot = $given.IndexOf('=')
|
||||
if ($pivot -lt 1) {
|
||||
Write-ErrorLine "preflight: --set needs <tool>=<path>, got '$given'"
|
||||
exit 1
|
||||
}
|
||||
$Sets[$given.Substring(0, $pivot)] = $given.Substring($pivot + 1)
|
||||
$index++
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Manifest -PathType Leaf)) {
|
||||
Write-ErrorLine "preflight: $Manifest is missing - this script has to run from inside an unpacked stack tree."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- platform -----------------------------------------------------------------
|
||||
|
||||
if ($env:CHEMENU_PREFLIGHT_PLATFORM) {
|
||||
$Platform = $env:CHEMENU_PREFLIGHT_PLATFORM
|
||||
} elseif ($IsWindows) {
|
||||
$Platform = 'windows'
|
||||
} elseif ($IsMacOS) {
|
||||
$Platform = 'macos'
|
||||
} else {
|
||||
$Platform = 'linux'
|
||||
}
|
||||
|
||||
# --- problems and the guidance block --------------------------------------------
|
||||
|
||||
$script:ProblemCount = 0
|
||||
$script:Guide = ''
|
||||
$script:BadSet = $false
|
||||
|
||||
function Add-Problem {
|
||||
param([string]$What, [string]$Why, [string]$Fix)
|
||||
$script:ProblemCount++
|
||||
$fixText = ($Fix -split "`n") -join "`n "
|
||||
$script:Guide += "`n$($script:ProblemCount)) $What`n Why: $Why`n Fix: $fixText`n Next: Tell the agent once this is done - it runs this check again.`n"
|
||||
}
|
||||
|
||||
function Exit-WithGuide {
|
||||
if ($script:ProblemCount -eq 1) {
|
||||
$noun = '1 thing needs'
|
||||
} else {
|
||||
$noun = "$($script:ProblemCount) things need"
|
||||
}
|
||||
Write-Line ''
|
||||
Write-Line "STOP - $noun your attention before this wiki can run."
|
||||
Write-Line '(Agent: show this output to the user exactly as it is, then wait. Do not install'
|
||||
Write-Line 'anything yourself and do not work around it.)'
|
||||
[Console]::Out.Write($script:Guide)
|
||||
exit 42
|
||||
}
|
||||
|
||||
# --- the manifest ---------------------------------------------------------------
|
||||
|
||||
$ManifestLines = @(
|
||||
Get-Content -LiteralPath $Manifest -Encoding utf8 |
|
||||
ForEach-Object { $_.TrimEnd("`r") } |
|
||||
Where-Object { $_.Trim() -ne '' -and -not $_.StartsWith('#') }
|
||||
)
|
||||
|
||||
function Get-ManifestField {
|
||||
param([string]$Kind, [string]$Name, [int]$Field)
|
||||
foreach ($line in $ManifestLines) {
|
||||
$parts = $line.Split('|')
|
||||
if ($parts.Count -gt 1 -and $parts[0] -eq $Kind -and $parts[1] -eq $Name) {
|
||||
if ($Field -le $parts.Count) {
|
||||
return $parts[$Field - 1]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
$Tools = @()
|
||||
foreach ($line in $ManifestLines) {
|
||||
$parts = $line.Split('|')
|
||||
if ($parts[0] -ne 'tool') {
|
||||
continue
|
||||
}
|
||||
if ($parts[1] -notmatch '^[a-z0-9]+$') {
|
||||
Write-ErrorLine "preflight: bad tool name '$($parts[1])' in $Manifest"
|
||||
exit 1
|
||||
}
|
||||
if ($parts[3] -eq 'all' -or $parts[3] -eq $Platform) {
|
||||
$Tools += $parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InstallHint {
|
||||
param([string]$Tool)
|
||||
$choco = Get-ManifestField 'tool' $Tool 7
|
||||
$winget = Get-ManifestField 'tool' $Tool 8
|
||||
$brew = Get-ManifestField 'tool' $Tool 9
|
||||
$apt = Get-ManifestField 'tool' $Tool 10
|
||||
$pacman = Get-ManifestField 'tool' $Tool 11
|
||||
$hint = ''
|
||||
switch ($Platform) {
|
||||
'windows' {
|
||||
if ((Get-Command choco -CommandType Application -ErrorAction SilentlyContinue) -and $choco -ne '-') {
|
||||
$hint = "$choco (in a terminal opened as administrator)"
|
||||
} elseif ((Get-Command winget -CommandType Application -ErrorAction SilentlyContinue) -and $winget -ne '-') {
|
||||
$hint = $winget
|
||||
}
|
||||
}
|
||||
'macos' {
|
||||
if ((Get-Command brew -CommandType Application -ErrorAction SilentlyContinue) -and $brew -ne '-') {
|
||||
$hint = $brew
|
||||
}
|
||||
}
|
||||
default {
|
||||
if ((Get-Command apt-get -CommandType Application -ErrorAction SilentlyContinue) -and $apt -ne '-') {
|
||||
$hint = $apt
|
||||
} elseif ((Get-Command pacman -CommandType Application -ErrorAction SilentlyContinue) -and $pacman -ne '-') {
|
||||
$hint = $pacman
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($hint) {
|
||||
return $hint
|
||||
}
|
||||
$lines = @('Install it with the package manager this computer uses, for example:')
|
||||
foreach ($entry in @($choco, $winget, $brew, $apt, $pacman)) {
|
||||
if ($entry -and $entry -ne '-') {
|
||||
$lines += " $entry"
|
||||
}
|
||||
}
|
||||
return ($lines -join "`n")
|
||||
}
|
||||
|
||||
# --- version helpers ------------------------------------------------------------
|
||||
|
||||
# major.minor of the first version-looking token in the text ("git version 2.47.1" -> 2.47)
|
||||
function Get-VersionOf {
|
||||
param([string]$Text)
|
||||
$first = ($Text -split "`n" | Select-Object -First 1)
|
||||
if ($null -eq $first) {
|
||||
return ''
|
||||
}
|
||||
$match = [regex]::Match($first, '(\d+)(?:\.(\d+))?')
|
||||
if (-not $match.Success) {
|
||||
return ''
|
||||
}
|
||||
if ($match.Groups[2].Success) {
|
||||
return "$($match.Groups[1].Value).$($match.Groups[2].Value)"
|
||||
}
|
||||
return $match.Groups[1].Value
|
||||
}
|
||||
|
||||
function Test-VersionGe {
|
||||
param([string]$Have, [string]$Want)
|
||||
$haveParts = ("$Have.0" -split '\.')[0, 1] | ForEach-Object { [int]$_ }
|
||||
$wantParts = ("$Want.0" -split '\.')[0, 1] | ForEach-Object { [int]$_ }
|
||||
if ($haveParts[0] -ne $wantParts[0]) {
|
||||
return $haveParts[0] -gt $wantParts[0]
|
||||
}
|
||||
return $haveParts[1] -ge $wantParts[1]
|
||||
}
|
||||
|
||||
# --- finding tools --------------------------------------------------------------
|
||||
|
||||
# The Microsoft Store's app-execution aliases: a python.exe that opens the Store
|
||||
# instead of running anything. Never executed, never recorded.
|
||||
function Test-StoreAlias {
|
||||
param([string]$Path)
|
||||
return $Path -match '(?i)[\\/]windowsapps[\\/]'
|
||||
}
|
||||
|
||||
function Test-Usable {
|
||||
param([string]$Path)
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf) -or (Test-StoreAlias $Path)) {
|
||||
return $false
|
||||
}
|
||||
if ($IsWindows) {
|
||||
return $true
|
||||
}
|
||||
return (([int][IO.File]::GetUnixFileMode($Path)) -band 73) -ne 0
|
||||
}
|
||||
|
||||
# PATH as this process has it, plus - on Windows - whatever the registry holds that a
|
||||
# long-running session has not picked up yet (a tool installed after it started).
|
||||
function Get-SearchDirectory {
|
||||
$separator = [IO.Path]::PathSeparator
|
||||
$directories = [Collections.Generic.List[string]]::new()
|
||||
$sources = @($env:PATH)
|
||||
if ($IsWindows) {
|
||||
foreach ($scope in 'Machine', 'User') {
|
||||
$sources += [Environment]::GetEnvironmentVariable('Path', $scope)
|
||||
}
|
||||
}
|
||||
foreach ($source in $sources) {
|
||||
if (-not $source) {
|
||||
continue
|
||||
}
|
||||
foreach ($entry in $source.Split($separator)) {
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($entry.Trim())
|
||||
if ($expanded -and -not $directories.Contains($expanded)) {
|
||||
$directories.Add($expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
return $directories
|
||||
}
|
||||
|
||||
# Every executable called <name> on PATH, in PATH order, store aliases dropped.
|
||||
function Find-OnPath {
|
||||
param([string]$Name)
|
||||
$found = @()
|
||||
foreach ($directory in (Get-SearchDirectory)) {
|
||||
foreach ($file in @($Name, "$Name.exe")) {
|
||||
$candidate = Join-Path $directory $file
|
||||
if (Test-Usable $candidate) {
|
||||
$found += $candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return $found
|
||||
}
|
||||
|
||||
# Runs a program; its standard output joined by newlines, or $null when it did not
|
||||
# start or did not exit 0.
|
||||
function Invoke-Native {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Same, but with the error stream merged in, for the messages the user is shown.
|
||||
function Invoke-NativeVerbose {
|
||||
param([string]$Path, [string[]]$Arguments = @())
|
||||
try {
|
||||
$output = & $Path @Arguments 2>&1
|
||||
return [pscustomobject]@{
|
||||
Code = $LASTEXITCODE
|
||||
Output = (@($output | ForEach-Object { "$_".TrimEnd("`r") }) -join "`n")
|
||||
}
|
||||
} catch {
|
||||
return [pscustomobject]@{ Code = -1; Output = $_.Exception.Message }
|
||||
}
|
||||
}
|
||||
|
||||
# The value recorded for <name> in .wikitool-tools.json.
|
||||
function Get-RecordedPath {
|
||||
param([string]$Name)
|
||||
if (-not (Test-Path -LiteralPath $ToolsFile -PathType Leaf)) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
$data = Get-Content -Raw -LiteralPath $ToolsFile | ConvertFrom-Json -AsHashtable
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if ($data -is [hashtable] -and $data.ContainsKey('tools') -and $data['tools'] -is [hashtable] -and
|
||||
$data['tools'].ContainsKey($Name)) {
|
||||
return [string]$data['tools'][$Name]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-SetValue {
|
||||
param([string]$Name)
|
||||
if ($Sets.ContainsKey($Name)) {
|
||||
return [string]$Sets[$Name]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
# --- --set: every named path has to work, or nothing is written -------------------
|
||||
|
||||
foreach ($name in $Sets.Keys) {
|
||||
if ($Tools -notcontains $name) {
|
||||
Write-ErrorLine "preflight: --set names '$name', which is not a tool this platform needs: $($Tools -join ' ')"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- python -------------------------------------------------------------------------
|
||||
|
||||
$script:Py = ''
|
||||
$script:PyVersion = ''
|
||||
$script:PyOld = $null
|
||||
$PyMin = Get-ManifestField 'tool' 'python' 3
|
||||
|
||||
# Sets Py/PyVersion on success, PyOld when the version is too old.
|
||||
function Test-PythonCandidate {
|
||||
param([string]$Path, [string[]]$Extra = @())
|
||||
$out = Invoke-Native -Path $Path -Arguments ($Extra + @('-c', $PyProbe))
|
||||
if ($null -eq $out) {
|
||||
return $false
|
||||
}
|
||||
$lines = $out -split "`n"
|
||||
if ($lines.Count -lt 2) {
|
||||
return $false
|
||||
}
|
||||
$version = $lines[0].Trim()
|
||||
$executable = $lines[1].Trim()
|
||||
if (-not $version -or -not $executable) {
|
||||
return $false
|
||||
}
|
||||
if (Test-VersionGe $version $PyMin) {
|
||||
$script:Py = $executable
|
||||
$script:PyVersion = $version
|
||||
return $true
|
||||
}
|
||||
$script:PyOld = @{ Version = $version; Path = $Path }
|
||||
return $false
|
||||
}
|
||||
|
||||
# py and py.exe are the launcher: they need -3
|
||||
function Get-PythonExtra {
|
||||
param([string]$Path)
|
||||
if ((Split-Path -Leaf $Path) -in 'py', 'py.exe') {
|
||||
return @('-3')
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
$given = Get-SetValue 'python'
|
||||
if ($given) {
|
||||
if (-not (Test-Usable $given) -or -not (Test-PythonCandidate $given (Get-PythonExtra $given))) {
|
||||
Add-Problem "The path given for Python does not work: $given" `
|
||||
"every wikitool command runs on Python $PyMin or newer, and this path did not start one" `
|
||||
"Check the path - it has to be the python executable itself, version $PyMin or newer.`nThen run: $Self --set python=<full path to python>"
|
||||
$script:BadSet = $true
|
||||
}
|
||||
} else {
|
||||
$previous = Get-RecordedPath 'python'
|
||||
if ($previous -and (Test-Usable $previous)) {
|
||||
[void](Test-PythonCandidate $previous)
|
||||
}
|
||||
if (-not $script:Py) {
|
||||
if ($Platform -eq 'windows') {
|
||||
$order = @(@('python', @()), @('py', @('-3')), @('python3', @()))
|
||||
} else {
|
||||
$order = @(@('python3', @()), @('python', @()))
|
||||
}
|
||||
foreach ($candidate in $order) {
|
||||
foreach ($path in (Find-OnPath $candidate[0])) {
|
||||
if (Test-PythonCandidate $path $candidate[1]) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($script:Py) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- the other tools --------------------------------------------------------------
|
||||
|
||||
$Report = @()
|
||||
$Found = @{}
|
||||
|
||||
function Add-Report {
|
||||
param([string]$Status, [string]$Name, [string]$Version, [string]$Path)
|
||||
$script:Report += ('{0,-8} {1,-7} {2,-8} {3}' -f $Status, $Name, $Version, $Path)
|
||||
}
|
||||
|
||||
if ($script:Py) {
|
||||
Add-Report 'OK' 'python' $script:PyVersion $script:Py
|
||||
$Found['python'] = $script:Py
|
||||
} elseif (-not $script:BadSet) {
|
||||
$label = Get-ManifestField 'tool' 'python' 5
|
||||
$why = Get-ManifestField 'tool' 'python' 6
|
||||
if ($script:PyOld) {
|
||||
Add-Report 'TOO_OLD' 'python' $script:PyOld.Version $script:PyOld.Path
|
||||
Add-Problem "$label $($script:PyOld.Version) is too old - this stack needs $PyMin or newer." $why `
|
||||
"$(Get-InstallHint 'python')`nOr, if a newer one is already installed, give its full path:`n$Self --set python=<full path to python>"
|
||||
} else {
|
||||
Add-Report 'MISSING' 'python' '-' '-'
|
||||
Add-Problem "$label $PyMin or newer was not found." $why `
|
||||
"$(Get-InstallHint 'python')`nOr, if it is installed somewhere this check did not look, give its full path:`n$Self --set python=<full path to python>"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tool in $Tools) {
|
||||
if ($tool -eq 'python') {
|
||||
continue
|
||||
}
|
||||
$label = Get-ManifestField 'tool' $tool 5
|
||||
$why = Get-ManifestField 'tool' $tool 6
|
||||
$minimum = Get-ManifestField 'tool' $tool 3
|
||||
$given = Get-SetValue $tool
|
||||
$path = ''
|
||||
if ($given) {
|
||||
if (Test-Usable $given) {
|
||||
$path = $given
|
||||
} else {
|
||||
Add-Problem "The path given for $label does not work: $given" $why `
|
||||
"Check the path - it has to be the $tool executable itself.`nThen run: $Self --set $tool=<full path to $tool>"
|
||||
$script:BadSet = $true
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
$previous = Get-RecordedPath $tool
|
||||
if ($tool -eq 'pwsh' -and (Test-Usable ([Environment]::ProcessPath))) {
|
||||
$path = [Environment]::ProcessPath
|
||||
} elseif ($previous -and (Test-Usable $previous)) {
|
||||
$path = $previous
|
||||
} else {
|
||||
$path = [string](Find-OnPath $tool | Select-Object -First 1)
|
||||
}
|
||||
}
|
||||
if (-not $path) {
|
||||
Add-Report 'MISSING' $tool '-' '-'
|
||||
Add-Problem "$label was not found." $why `
|
||||
"$(Get-InstallHint $tool)`nOr, if it is installed somewhere this check did not look, give its full path:`n$Self --set $tool=<full path to $tool>"
|
||||
continue
|
||||
}
|
||||
$version = Get-VersionOf ([string](Invoke-Native -Path $path -Arguments @('--version')))
|
||||
if ($minimum -ne '-' -and (-not $version -or -not (Test-VersionGe $version $minimum))) {
|
||||
$shown = if ($version) { $version } else { 'unknown' }
|
||||
Add-Report 'TOO_OLD' $tool $shown $path
|
||||
Add-Problem "$label $(if ($version) { $version } else { 'of unknown version' }) is too old - this stack needs $minimum or newer." $why `
|
||||
(Get-InstallHint $tool)
|
||||
continue
|
||||
}
|
||||
Add-Report 'OK' $tool $(if ($version) { $version } else { '-' }) $path
|
||||
$Found[$tool] = $path
|
||||
}
|
||||
|
||||
# --- install folder length (Windows, long paths off) ------------------------------
|
||||
|
||||
function Test-LongPathsEnabled {
|
||||
if ($env:CHEMENU_PREFLIGHT_LONGPATHS) {
|
||||
return $env:CHEMENU_PREFLIGHT_LONGPATHS -eq '1'
|
||||
}
|
||||
# An unreadable key counts as "off": the limit then protects a machine it did not
|
||||
# have to.
|
||||
if (-not $IsWindows) {
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
$value = Get-ItemPropertyValue -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled
|
||||
return $value -eq 1
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($Platform -eq 'windows' -and -not (Test-LongPathsEnabled)) {
|
||||
$limit = [int](Get-ManifestField 'limit' 'install_dir_max' 3)
|
||||
$length = $Root.Length
|
||||
if ($length -gt $limit) {
|
||||
Add-Problem "The folder this wiki is installed in is too long ($length characters, at most $limit): $Root" `
|
||||
"Windows on this computer only allows paths of up to 259 characters, and the wiki's own files need the rest" `
|
||||
"Move the wiki to a shorter folder, for example C:\Chemenu, and run this check there.`nAlternatively, someone with administrator rights can turn on long paths in Windows."
|
||||
}
|
||||
}
|
||||
|
||||
# --- execution policy and Mark of the Web (Windows) -------------------------------
|
||||
|
||||
# The policy that decides whether tools/wikitool.ps1 starts in an ordinary pwsh: the
|
||||
# first scope that sets one, the group policies first. This process's own scope is left
|
||||
# out - it holds the bypass this script was started with.
|
||||
function Get-PolicyEntry {
|
||||
if ($env:CHEMENU_PREFLIGHT_POLICY) {
|
||||
$entries = @()
|
||||
foreach ($pair in $env:CHEMENU_PREFLIGHT_POLICY.Split(',')) {
|
||||
$scope, $policy = $pair.Split('=', 2)
|
||||
$entries += [pscustomobject]@{ Scope = $scope.Trim(); ExecutionPolicy = $policy.Trim() }
|
||||
}
|
||||
return $entries
|
||||
}
|
||||
return @(Get-ExecutionPolicy -List)
|
||||
}
|
||||
|
||||
function Test-ExecutionPolicy {
|
||||
$effective = $null
|
||||
foreach ($scope in 'MachinePolicy', 'UserPolicy', 'CurrentUser', 'LocalMachine') {
|
||||
$entry = Get-PolicyEntry | Where-Object { [string]$_.Scope -eq $scope } | Select-Object -First 1
|
||||
if ($entry -and [string]$entry.ExecutionPolicy -ne 'Undefined') {
|
||||
$effective = @{ Scope = $scope; Policy = [string]$entry.ExecutionPolicy }
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($null -eq $effective -or $effective.Policy -notin 'Restricted', 'AllSigned') {
|
||||
return
|
||||
}
|
||||
$why = 'tools/wikitool.ps1 is not signed, and this policy only lets signed scripts (or none) run'
|
||||
if ($effective.Scope -in 'MachinePolicy', 'UserPolicy') {
|
||||
Add-Problem "A group policy ($($effective.Scope)) sets the PowerShell execution policy to $($effective.Policy)." $why `
|
||||
"A group policy cannot be overridden from this computer. Ask whoever looks after it to allow locally written scripts (RemoteSigned) for PowerShell 7,`nor run the wiki's commands from Git Bash (tools/wikitool instead of tools/wikitool.ps1)."
|
||||
} else {
|
||||
Add-Problem "The PowerShell execution policy is $($effective.Policy) (set for $($effective.Scope))." $why `
|
||||
"In a PowerShell 7 window, run:`nSet-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned"
|
||||
}
|
||||
}
|
||||
|
||||
# Scripts below tools/ that carry a Mark of the Web from the internet zone - a file
|
||||
# saved by a browser or unpacked from such a download in Explorer. Invoke-WebRequest and
|
||||
# tar set none, so this only turns up on the manual path.
|
||||
function Get-MarkedScript {
|
||||
if ($env:CHEMENU_PREFLIGHT_MARKED) {
|
||||
return @($env:CHEMENU_PREFLIGHT_MARKED.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
if (-not $IsWindows) {
|
||||
return @()
|
||||
}
|
||||
$marked = @()
|
||||
foreach ($file in Get-ChildItem -LiteralPath $Dir -Filter '*.ps1' -Recurse -File) {
|
||||
if ($file.FullName.StartsWith($Venv, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
continue
|
||||
}
|
||||
$zone = Get-Content -LiteralPath $file.FullName -Stream Zone.Identifier -ErrorAction SilentlyContinue
|
||||
if ($zone -match 'ZoneId=([3-9])') {
|
||||
$marked += $file.FullName.Substring($Dir.Length + 1)
|
||||
}
|
||||
}
|
||||
return $marked
|
||||
}
|
||||
|
||||
if ($Platform -eq 'windows') {
|
||||
Test-ExecutionPolicy
|
||||
$marked = @(Get-MarkedScript)
|
||||
if ($marked.Count -gt 0) {
|
||||
$listed = (($marked | Select-Object -First 5) -join ', ') + $(if ($marked.Count -gt 5) { ', ...' } else { '' })
|
||||
Add-Problem "Windows marks some of this wiki's scripts as downloaded from the internet: $listed" `
|
||||
'PowerShell refuses to run a marked script under its usual settings - tools/wikitool.ps1 would not start. This happens when the wiki was downloaded with a browser and unpacked in Explorer' `
|
||||
"In a PowerShell 7 window, run:`nGet-ChildItem -LiteralPath '$Root' -Recurse -File | Unblock-File"
|
||||
}
|
||||
}
|
||||
|
||||
# --- record what was found ----------------------------------------------------------
|
||||
|
||||
function ConvertTo-JsonString {
|
||||
param([string]$Text)
|
||||
return $Text.Replace('\', '\\').Replace('"', '\"')
|
||||
}
|
||||
|
||||
function Write-ToolsFile {
|
||||
param([bool]$Complete)
|
||||
$flag = if ($Complete) { 'true' } else { 'false' }
|
||||
$text = "{`n `"schema`": 1,`n `"complete`": $flag,`n `"tools`": {"
|
||||
$separator = ''
|
||||
foreach ($tool in $Tools) {
|
||||
if ($Found.ContainsKey($tool)) {
|
||||
$text += "$separator`n `"$tool`": `"$(ConvertTo-JsonString $Found[$tool])`""
|
||||
$separator = ','
|
||||
}
|
||||
}
|
||||
$text += "`n }`n}`n"
|
||||
$temporary = "$ToolsFile.tmp.$PID"
|
||||
[IO.File]::WriteAllText($temporary, $text, [Text.UTF8Encoding]::new($false))
|
||||
Move-Item -LiteralPath $temporary -Destination $ToolsFile -Force
|
||||
}
|
||||
|
||||
foreach ($line in $Report) {
|
||||
Write-Line $line
|
||||
}
|
||||
|
||||
if ($script:BadSet) {
|
||||
Exit-WithGuide # nothing is written for a path that does not work
|
||||
}
|
||||
Write-ToolsFile $false
|
||||
if ($script:ProblemCount -gt 0) {
|
||||
Exit-WithGuide
|
||||
}
|
||||
|
||||
# --- tools/.venv ----------------------------------------------------------------------
|
||||
|
||||
function Get-VenvPython {
|
||||
$unix = Join-Path (Join-Path $Venv 'bin') 'python'
|
||||
$windows = Join-Path (Join-Path $Venv 'Scripts') 'python.exe'
|
||||
if ((Test-Path -LiteralPath $unix -PathType Leaf) -and (Test-Usable $unix)) {
|
||||
return $unix
|
||||
}
|
||||
if (Test-Path -LiteralPath $windows -PathType Leaf) {
|
||||
return $windows
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-LastLine {
|
||||
param([string]$Text)
|
||||
return ((($Text -split "`n") | Select-Object -Last 5 | ForEach-Object { " $_" }) -join "`n")
|
||||
}
|
||||
|
||||
$venvPython = Get-VenvPython
|
||||
if (-not $venvPython -or $null -eq (Invoke-Native -Path $venvPython -Arguments @('-m', 'pip', '--version'))) {
|
||||
$created = Invoke-NativeVerbose -Path $script:Py -Arguments @('-m', 'venv', '--clear', $Venv)
|
||||
$venvPython = Get-VenvPython
|
||||
if ($created.Code -ne 0 -or -not $venvPython) {
|
||||
$fix = "Python said:`n$(Get-LastLine $created.Output)"
|
||||
if ($Platform -eq 'linux' -and (Get-Command apt-get -CommandType Application -ErrorAction SilentlyContinue)) {
|
||||
$fix = "sudo apt install python3-venv`n$fix"
|
||||
}
|
||||
Add-Problem 'The wiki''s own Python environment (tools/.venv) could not be created.' `
|
||||
'wikitool runs in that environment, so that nothing it installs touches the rest of the computer' `
|
||||
$fix
|
||||
Exit-WithGuide
|
||||
}
|
||||
}
|
||||
|
||||
$want = (Get-FileHash -LiteralPath $Requirements -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$have = ''
|
||||
if (Test-Path -LiteralPath $Stamp -PathType Leaf) {
|
||||
$have = (Get-Content -Raw -LiteralPath $Stamp).Trim()
|
||||
}
|
||||
if ($want -ne $have) {
|
||||
$installed = Invoke-NativeVerbose -Path $venvPython -Arguments @('-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '-r', $Requirements)
|
||||
if ($installed.Code -ne 0) {
|
||||
Add-Problem 'The libraries wikitool needs could not be installed into tools/.venv.' `
|
||||
'they are downloaded once from the internet; without them no wikitool command starts' `
|
||||
"Check that this computer can reach the internet (a proxy or a security program can block it; if so, ask whoever looks after this computer).`npip said:`n$(Get-LastLine $installed.Output)"
|
||||
Exit-WithGuide
|
||||
}
|
||||
[IO.File]::WriteAllText($Stamp, "$want`n", [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
Write-Line ('OK venv - {0}' -f $Venv)
|
||||
|
||||
Write-ToolsFile $true
|
||||
Write-Line ''
|
||||
Write-Line 'Preflight passed. Tool paths are recorded in .wikitool-tools.json; tools/wikitool is ready.'
|
||||
exit 0
|
||||
+8
-3
@@ -9,9 +9,10 @@
|
||||
#
|
||||
# Nothing here sets up an environment. tools/preflight.sh does that, once per
|
||||
# checkout and again after every stack update: it records the tool paths in
|
||||
# .wikitool-tools.json and creates tools/.venv. Until it has passed, this script
|
||||
# stops with exit 42 and names it - the preflight is the one step that must not
|
||||
# be skipped or improvised around (instructions/preflight.md).
|
||||
# .wikitool-tools.json and creates tools/.venv (tools/preflight.ps1 is its
|
||||
# PowerShell twin). Until it has passed, this script stops with exit 42 and
|
||||
# names it - the preflight is the one step that must not be skipped or
|
||||
# improvised around (instructions/preflight.md).
|
||||
set -eu
|
||||
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT=$(dirname -- "$DIR")
|
||||
@@ -34,6 +35,10 @@ paths and creates tools/.venv:
|
||||
|
||||
tools/preflight.sh
|
||||
|
||||
From PowerShell 7 (Windows), run this one instead:
|
||||
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
|
||||
It exits 0 when everything is in place. If it exits 42, show its output to the
|
||||
user as it is and wait - it says what to do. See instructions/preflight.md.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#Requires -Version 7
|
||||
# Entry point for the wikitool CLI under PowerShell 7, so agents and people invoke it
|
||||
# through the same string on every platform:
|
||||
#
|
||||
# tools/wikitool <command> [options]
|
||||
#
|
||||
# PowerShell resolves that to this file first. Without it, `tools/wikitool` is the sh
|
||||
# launcher next to it, which PowerShell does not run: the command would end silently
|
||||
# without printing anything. The POSIX half - Linux, macOS and Git Bash - is tools/wikitool.
|
||||
#
|
||||
# Nothing here sets up an environment. tools/preflight.ps1 does that, once per checkout and
|
||||
# again after every stack update: it records the tool paths in .wikitool-tools.json and
|
||||
# creates tools/.venv. Until it has passed, this script stops with exit 42 and names it -
|
||||
# the preflight is the one step that must not be skipped or improvised around
|
||||
# (instructions/preflight.md).
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandArgumentPassing = 'Standard'
|
||||
|
||||
$Dir = $PSScriptRoot
|
||||
$Root = Split-Path -Parent $Dir
|
||||
$ToolsFile = Join-Path $Root '.wikitool-tools.json'
|
||||
|
||||
# Both venv layouts: Scripts\ on Windows, bin/ everywhere else.
|
||||
$Python = ''
|
||||
foreach ($candidate in @((Join-Path $Dir '.venv/Scripts/python.exe'), (Join-Path $Dir '.venv/bin/python'))) {
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
$Python = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$Complete = (Test-Path -LiteralPath $ToolsFile -PathType Leaf) -and
|
||||
((Get-Content -Raw -LiteralPath $ToolsFile) -match '"complete":\s*true')
|
||||
|
||||
if (-not $Python -or -not $Complete) {
|
||||
[Console]::Error.WriteLine(@'
|
||||
STOP - this checkout is not set up yet (or no longer after an update).
|
||||
Run the preflight first; it checks what this computer has, records the tool
|
||||
paths and creates tools/.venv:
|
||||
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1
|
||||
|
||||
It exits 0 when everything is in place. If it exits 42, show its output to the
|
||||
user as it is and wait - it says what to do. See instructions/preflight.md.
|
||||
'@)
|
||||
exit 42
|
||||
}
|
||||
|
||||
# Do not change into $Dir: that would resolve relative CLI arguments (for example
|
||||
# --markdown "kb/Lint Report.md") against tools/ instead of the caller's directory.
|
||||
& $Python (Join-Path $Dir 'run_wikitool.py') @args
|
||||
exit $LASTEXITCODE
|
||||
Reference in new issue
Block a user