Twelve scripts retired, three domains registered. `reach` now covers nine. generate: `generate-brands` and `generate-corporations` were the second and third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas` carried, so they collapsed into `core.process.cargo_binary` rather than being ported. `import_economics` shelled out to the first of those, so it now calls that helper — `generated_brands.toml` comes back byte-identical, and the stamp registry swaps the retired wrapper for `core/process.py`. pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead of restating it, so it cannot drift from the stamp check. dev: the environment scripts split decision from performing, per D-263's guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it. `tooling/test_environment.py` pins the version pin, both override precedences, the already-current skip, the platform refusal and both worktree refusals — none of them performed. `make setup` now installs reach first, since the targets that install rust and godot are reach verbs. Two live bugs found while porting: - The clerk read its decision index from `decisions/README.md`, a path that stopped existing when the DQR tree moved to `governance/`. Every clerk agent has been grepping blind; its prompt pointed at the same dead directory. - The conformance exec-check matched any `x.system()` regardless of receiver, so `platform.system()` read as `os.system()`. Narrowed and re-proved against a real mutant. `process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a killed run stays distinguishable from a verdict. The pre-push hook no longer merges the clerk's stderr into its stdout — under streaming the last merged line is a JSONL event, which would read as an unrecognised verdict and block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
7.2 KiB
Python
204 lines
7.2 KiB
Python
"""Environment setup — the decisions, separated from the performing (D-263).
|
|
|
|
These three ported worst and mattered most to get right. `install-godot`
|
|
downloads and unzips a pinned build, `install-rust` drives `rustup`,
|
|
`worktree-setup` manipulates git worktrees — none of which can be exercised in
|
|
a gate without actually doing it.
|
|
|
|
So the shape here is deliberate: **every function that decides is pure and
|
|
importable, and every function that acts is a thin call through
|
|
`core.process.run`.** `godot_plan()` answers "what would be downloaded, and is
|
|
it needed" without touching the network; `install_godot()` performs it. The
|
|
test of a correct port is whether the decisions can be exercised WITHOUT
|
|
performing them — a rewrite that cannot be tested has to be trusted instead,
|
|
and trusting an installer is how a working environment becomes an
|
|
unreproducible one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config, console, process
|
|
from tooling.core.errors import ReachError
|
|
|
|
DEFAULT_GODOT_VERSION = "4.6"
|
|
INSTALL_DIR = Path.home() / "bin"
|
|
GODOT_BINARY = INSTALL_DIR / "godot4"
|
|
|
|
# Godot publishes one archive per platform triple; an unsupported pair is a
|
|
# clear failure rather than a download that 404s.
|
|
PLATFORMS = {
|
|
("Linux", "x86_64"): "linux.x86_64",
|
|
("Linux", "aarch64"): "linux.arm64",
|
|
("Darwin", "x86_64"): "macos.universal",
|
|
("Darwin", "arm64"): "macos.universal",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GodotPlan:
|
|
"""What installing Godot would do — decided without doing any of it."""
|
|
|
|
wanted: str
|
|
installed: str | None
|
|
platform_tag: str
|
|
url: str
|
|
filename: str
|
|
|
|
@property
|
|
def already_current(self) -> bool:
|
|
return self.installed == self.wanted
|
|
|
|
|
|
def godot_plan(wanted: str | None = None) -> GodotPlan:
|
|
"""Decide what an install would fetch. Pure apart from reading the binary."""
|
|
wanted = wanted or os.environ.get("GODOT_VERSION", DEFAULT_GODOT_VERSION)
|
|
system, machine = platform.system(), platform.machine()
|
|
tag = PLATFORMS.get((system, machine))
|
|
if tag is None:
|
|
raise ReachError(
|
|
f"unsupported platform: {system} {machine}",
|
|
fix="install Godot manually from https://godotengine.org/download",
|
|
)
|
|
|
|
filename = f"Godot_v{wanted}-stable_{tag}.zip"
|
|
return GodotPlan(
|
|
wanted=wanted,
|
|
installed=installed_godot_version(),
|
|
platform_tag=tag,
|
|
filename=filename,
|
|
url=(
|
|
"https://github.com/godotengine/godot/releases/download/"
|
|
f"{wanted}-stable/{filename}"
|
|
),
|
|
)
|
|
|
|
|
|
def installed_godot_version() -> str | None:
|
|
"""The major.minor already on disk, or None. Never raises."""
|
|
if not GODOT_BINARY.is_file() or not os.access(GODOT_BINARY, os.X_OK):
|
|
return None
|
|
result = process.run([str(GODOT_BINARY), "--version"], check=False)
|
|
first = (result.stdout or "").splitlines()
|
|
if not first:
|
|
return None
|
|
return ".".join(first[0].split(".")[:2])
|
|
|
|
|
|
def install_godot(wanted: str | None = None) -> GodotPlan:
|
|
"""Perform the install. The decision is `godot_plan`; this is the doing."""
|
|
plan = godot_plan(wanted)
|
|
if plan.already_current:
|
|
console.verdict(f"Godot {plan.wanted} already installed at {GODOT_BINARY}")
|
|
return plan
|
|
if plan.installed:
|
|
console.event(
|
|
f"Godot found but version is {plan.installed}, want {plan.wanted}",
|
|
level="warn",
|
|
)
|
|
|
|
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
archive = Path(tmp) / plan.filename
|
|
console.event(f"downloading Godot {plan.wanted} for {plan.platform_tag}", phase="godot")
|
|
process.run(
|
|
["curl", "-fSL", "--progress-bar", "-o", str(archive), plan.url],
|
|
capture=False,
|
|
fix=f"check that GODOT_VERSION={plan.wanted} is a real release",
|
|
missing_fix="install curl",
|
|
)
|
|
console.event("extracting", phase="godot")
|
|
process.run(
|
|
["unzip", "-q", "-o", str(archive), "-d", tmp],
|
|
fix="the archive may be truncated — re-run to download it again",
|
|
missing_fix="install unzip",
|
|
)
|
|
extracted = next(
|
|
(p for p in Path(tmp).iterdir() if p.is_file() and p.name.startswith("Godot")),
|
|
None,
|
|
)
|
|
if extracted is None:
|
|
raise ReachError(
|
|
"the Godot archive contained no binary",
|
|
fix="the download may be corrupt — delete it and re-run",
|
|
)
|
|
shutil.move(str(extracted), GODOT_BINARY)
|
|
GODOT_BINARY.chmod(0o755)
|
|
|
|
console.verdict(f"Godot {plan.wanted} installed at {GODOT_BINARY}")
|
|
return plan
|
|
|
|
|
|
def install_rust() -> None:
|
|
"""Ensure rustup, clippy and rustfmt are present."""
|
|
if shutil.which("cargo") is None:
|
|
raise ReachError(
|
|
"cargo is not installed",
|
|
fix="install Rust from https://rustup.rs, then re-run — this command "
|
|
"adds the components but does not bootstrap the toolchain",
|
|
)
|
|
for component in ("clippy", "rustfmt"):
|
|
console.event(f"ensuring {component}", phase="rust")
|
|
process.run(
|
|
["rustup", "component", "add", component],
|
|
check=False,
|
|
missing_fix="install rustup from https://rustup.rs",
|
|
)
|
|
console.verdict("Rust toolchain ready — clippy and rustfmt present")
|
|
|
|
|
|
def worktree_plan(branch: str) -> Path:
|
|
"""Where a worktree for `branch` would go. Raises if it cannot be created.
|
|
|
|
Both refusals are the original's and both are worth keeping: a worktree
|
|
created from inside a worktree nests confusingly, and silently reusing an
|
|
existing directory is how two branches end up sharing one tree.
|
|
"""
|
|
root = config.repo_root()
|
|
if (root / ".git").is_file():
|
|
raise ReachError(
|
|
"this is a worktree, not the main checkout",
|
|
fix="run this from the main checkout — nesting worktrees confuses "
|
|
"both git and the tools that resolve the repo root",
|
|
)
|
|
target = root / ".worktrees" / branch
|
|
if target.exists():
|
|
raise ReachError(
|
|
f"{target} already exists",
|
|
fix=f"reuse it, or remove it first with: git worktree remove {target}",
|
|
)
|
|
return target
|
|
|
|
|
|
def setup_worktree(branch: str, start: str = "HEAD") -> Path:
|
|
"""Create a worktree and link the venv into it."""
|
|
target = worktree_plan(branch)
|
|
root = config.repo_root()
|
|
|
|
process.run(
|
|
["git", "worktree", "add", str(target), "-b", branch, start],
|
|
cwd=root,
|
|
fix=f"check that {start} is a valid ref and {branch} is not already a branch",
|
|
)
|
|
|
|
venv = root / ".venv"
|
|
if venv.is_dir() and not (target / ".venv").exists():
|
|
(target / ".venv").symlink_to(venv)
|
|
console.event(f"linked .venv -> {venv}")
|
|
|
|
console.verdict(
|
|
f"worktree ready: {target}\n"
|
|
f" pql in this worktree needs --vault {target} (FR-4)\n"
|
|
" tea runs from the main checkout — its go-git cannot read a linked worktree\n"
|
|
" reach follows whichever checkout it was installed from; `make reach-repoint` "
|
|
"if you want it to follow this one"
|
|
)
|
|
return target
|