diff --git a/Cargo.lock b/Cargo.lock index 450d458..316f95b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,7 +1551,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "retch-cli" -version = "0.6.19" +version = "0.6.20" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 942cf60..56f312e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "retch-cli" -version = "0.6.19" +version = "0.6.20" edition = "2021" authors = ["Ken Tobias"] description = "A fast, feature-rich system information fetcher written in Rust (similar to fastfetch or neofetch)" diff --git a/Justfile b/Justfile index 16c552c..579baa9 100644 --- a/Justfile +++ b/Justfile @@ -6,6 +6,92 @@ # Required for shebang recipes to receive *ARGS as real argv ($@) instead of # losing quoting via textual {{ARGS}} interpolation (see open-pr). +# ===== PROJECT — the only part of the install family this repo owns ===== +# +# The COMMON block below is written against these so it can be byte-identical across repos +# that ship different binaries. `etr` sets BINS to two names; this repo has one. +BINS := "retch" +MAN_PAGES := "docs/retch.1" + +# Do NOT edit inside the markers below. Edit templates/justfile-common.just and the two +# vendored helpers, bump their versions, and propagate to the sibling repos in their own PRs. +# `just standard-check` runs the helpers' self-tests and `just check` depends on it, so a +# violation fails the build rather than being discovered years later. +# >>> COMMON (template v2) +# The interpreter is resolved ONCE per line, and a missing one is a hard error. The +# `python3 … 2>/dev/null || python …` idiom is deliberately NOT used: it retries on ANY +# failure, so a real error inside the script gets re-run and reported as if the +# interpreter were the problem. +PY := `command -v python3 || command -v python || echo PYTHON-NOT-FOUND` + +# Install from this checkout: binary, man page(s) and completions. +# +# The dependencies are the point. `cargo install` alone replaces the binary and leaves the +# man page and completions at whatever version last ran their recipe — measured on a host +# whose page was ELEVEN releases stale with nothing reporting it. +install: install-man install-completions + cargo install --path . + +# Install a RELEASED tag: binary, man page(s) and completions, all three FROM THAT TAG. +# +# **It deliberately does NOT depend on `install-man`/`install-completions`**, because those +# work from the checkout. Reusing them would pair a tag's binary with the worktree's man +# page and completions — on a checkout one release ahead, a v0.2.22 binary with a v0.2.23 +# page. Mismatched artefacts that each look fine is the failure class this standard exists +# to remove, so the three sources are made to agree: binary from the tag, completions from +# THE INSTALLED BINARY (`--from-path`), man page from the tag (`--from-tag`). +# +# Never `--path`: on a Syncthing-shared checkout that builds from a directory other +# machines write into. Takes a bare version and normalises a leading `v`. +install-tag VERSION: + #!/usr/bin/env bash + set -euo pipefail + V="{{VERSION}}"; V="${V#v}" + [ -n "$V" ] || { echo "error: install-tag needs a version, e.g. just install-tag 0.2.22" >&2; exit 1; } + git rev-parse -q --verify "refs/tags/v${V}" >/dev/null || { + echo "error: tag v${V} is not in this clone. Run: git fetch --tags" >&2; exit 1; } + REPO=$(git config --get remote.origin.url) + echo "Installing from tag v${V} of ${REPO}" + cargo install --git "$REPO" --tag "v${V}" --locked --force + # POST-CONDITION: cargo prints a replacement line, but only a version query proves which + # binary is on PATH now. + for b in {{BINS}}; do + command -v "$b" >/dev/null 2>&1 || { echo "error: $b is not on PATH after install" >&2; exit 1; } + echo " $b -> $("$b" --version)" + done + "{{PY}}" scripts/install_man.py {{MAN_PAGES}} --from-tag "v${V}" + "{{PY}}" scripts/install_completions.py {{BINS}} --from-path + +# Install the man page(s) to the XDG man directory. +install-man: man + @"{{PY}}" scripts/install_man.py {{MAN_PAGES}} + +# Generate and install shell completions for every binary. +# +# Python rather than a just recipe, which is retch's finding and the more portable +# mechanism: no `sh`, no `cygpath`, no coreutils, nothing from Git's `usr\bin` on Windows. +# A `bash` shebang recipe cannot run on Windows without `cygpath` at all, and even a plain +# `sh` recipe still needs an `sh` on PATH. +install-completions: build + @"{{PY}}" scripts/install_completions.py {{BINS}} + +# Prove the vendored helpers still behave the way the standard requires. +# +# **This runs the helpers' own self-tests rather than diffing text**, and that is the whole +# point: three separate repositories cannot diff each other's files, but each can prove its +# copy still behaves correctly — which is the property that was actually violated when two +# repos quietly shipped the pre-fix nushell path for months. A text diff would also have +# passed happily on a repo that had never adopted the standard at all. +standard-check: + #!/usr/bin/env bash + set -euo pipefail + [ "{{PY}}" != "PYTHON-NOT-FOUND" ] || { echo "error: no python3/python on PATH" >&2; exit 1; } + "{{PY}}" scripts/install_completions.py --self-test + "{{PY}}" scripts/install_man.py --self-test +# <<< COMMON + +# ===== PROJECT-SPECIFIC — everything below is this repo's own ===== + set positional-arguments := true # Default recipe @@ -37,7 +123,7 @@ lint: cargo clippy --workspace -- -D warnings # Run strict checks (formatting and linting) as done in CI -check: +check: standard-check cargo fmt -- --check cargo clippy --workspace -- -D warnings # Also lint the optional `graphics` feature (base64/image/icy_sixel in src/logo.rs), @@ -50,22 +136,10 @@ audit: @command -v cargo-audit >/dev/null || cargo install cargo-audit cargo audit -# Install the binary, man page, and shell completions -install: install-man install-completions - cargo install --path . - # Generate man page from Markdown using mandown (requires: mandown) man: @python3 scripts/build_man.py 2>/dev/null || python scripts/build_man.py -# Install man page to XDG user location (~/.local/share/man) -install-man: man - @python3 scripts/install_man.py 2>/dev/null || python scripts/install_man.py - -# Install shell completions for all supported shells to XDG user locations -install-completions: build - @python3 scripts/install_completions.py 2>/dev/null || python scripts/install_completions.py - # Convert all SVGs to PNGs (used for embedded logos) logos: #!/usr/bin/env bash diff --git a/NOTES.md b/NOTES.md index df6ed29..1d8af02 100644 --- a/NOTES.md +++ b/NOTES.md @@ -96,7 +96,62 @@ The `retch-sysinfo` crate can be used independently as a library for cross-platf --- -## Current State (v0.6.19) +## Current State (v0.6.20) +- **v0.6.20 — nushell completions went where Windows nushell never looks; the install helpers + become a checked cross-repo standard** (tooling only; no runtime behavior change, + `retch-sysinfo` unchanged at `0.1.53`). + - **The bug: `scripts/install_completions.py` wrote nushell completions to + `$XDG_CONFIG_HOME/nushell/autoload`.** On Windows `$nu.user-autoload-dirs` is exactly + `%APPDATA%\nushell\autoload` — one entry — and nushell there does **not** read the XDG path at + all, whatever `XDG_CONFIG_HOME` says. So the helper wrote a real file to a directory nothing + consults, printed `Installed completions for retch:` with its full path, and delivered nothing. + Measured in `rusticprofile` (its `0.2.14`), where the same defect explained shell aliases absent + for months while present in the dotfiles the whole time. **Silent in exactly the direction that + matters.** + - **A second defect, in the reporting rather than the paths: the helper survived a failure and + then claimed success.** `except subprocess.CalledProcessError` logged to stderr and continued, + and `print("Installed completions for retch:")` plus the full path list ran unconditionally + afterwards — a step reporting success over work it did not do. The canonical helper raises. + - **Third, milder: nothing checked whether zsh would ever load the file.** zsh reads completion + functions only from directories on `fpath`, and `~/.local/share/zsh/site-functions` is not on it + by default on any distribution. This helper did not *claim* auto-loading (etr's does), but it did + not warn either. It now checks, using an **interactive** zsh — a non-interactive one sources + neither `.zshrc` nor anything it includes, so asking it returns the built-in default and gets the + answer confidently wrong in the other direction. + - **This repo's mechanism was right and is now the standard.** `v0.6.16` moved these recipes to + Python precisely so they run natively on Windows without Git's `usr\bin`, and that holds: + `rusticprofile` first proposed replacing them with plain-`sh` recipes because *it* held the + correctness fixes, which would have regressed this repo's portability work in the name of + consistency. **The two repos had each solved half the problem.** The standard keeps retch's + mechanism and rusticprofile's correctness. + - **`scripts/install_completions.py` and `scripts/install_man.py` are now canonical and vendored + byte-identically** across `retch`, `rusticprofile` and `etr` (`TEMPLATE_VERSION = 2`), alongside + `templates/justfile-common.just` — the reference for the Justfile block between + `# >>> COMMON (template v2)` and `# <<< COMMON`. Project facts (`BINS`, `MAN_PAGES`) sit in a + `PROJECT` header above it, because `etr` ships two binaries and a block with a hardcoded name + cannot be copied. + - **`just standard-check` runs the helpers' `--self-test`, and `just check` depends on it.** Not a + text diff: three separate repositories cannot diff each other's files, and a text diff would pass + happily on a repo that never adopted the standard. The self-tests assert the invariants directly + — the Windows nushell path, that `APPDATA` disturbs none of the other five directories, that XDG + overrides are honoured, and that an *empty* XDG variable falls back rather than resolving every + path against `/`. Each was watched failing; reverting the nushell line reports + `windows nushell dir: expected …AppData\Roaming… got …/.config/…`. + - **New recipe `install-tag VERSION`** — installs a released tag with all three artefacts *from + that tag*: binary via `cargo install --git --tag`, completions generated by **the installed + binary** so they cannot disagree with its CLI, and the man page read out of the tag via + `git show`. It deliberately does not depend on `install-man`/`install-completions`, which work + from the checkout: reusing them would pair a tag's binary with the worktree's man page. Prompted + by a fleet host found running a current binary beside a man page **eleven releases old**, because + every upgrade was a hand-typed `cargo install` and nothing re-ran the other two recipes. + - **The `python3 … 2>/dev/null || python …` idiom is gone.** It retries on *any* failure, so a real + error inside the script was re-run and reported as though the interpreter were missing. The + interpreter resolves once into `PY`, and its absence is a named error. + - Scope: the standard covers the install/man/completions family only. `check`/`lint`/`test`/`pr` + legitimately differ across the three repos (`--workspace` here, `--all-targets` in etr, bare in + rusticprofile) and reconciling them is a behaviour change per repo rather than a copy; + `templates/justfile-common.just` records that and the other known divergences. + - `retch-cli` → 0.6.20. Patch bump. - **v0.6.19 — dependency bumps (consolidated Dependabot #188)** (chore; no runtime behavior change). Rolls Dependabot's PR onto a gated branch so the release hygiene it bypasses — version bump, NOTES entry, man regen — is actually done, following the #167/v0.6.3 and diff --git a/docs/retch.1 b/docs/retch.1 index 376513f..aa52067 100644 --- a/docs/retch.1 +++ b/docs/retch.1 @@ -1,4 +1,4 @@ -.TH "RETCH" "1" "August 2026" "retch 0.6.19" "System Information Fetcher" +.TH "RETCH" "1" "August 2026" "retch 0.6.20" "System Information Fetcher" .SH "NAME" .PP diff --git a/scripts/install_completions.py b/scripts/install_completions.py index 90a3846..beb0312 100644 --- a/scripts/install_completions.py +++ b/scripts/install_completions.py @@ -1,60 +1,245 @@ #!/usr/bin/env python3 -"""Cross-platform installer for retch shell completions.""" +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 l1a +"""Install shell completions for one or more binaries. Canonical across repos. + +TEMPLATE v2 — vendored verbatim in rusticprofile, retch and etr. Change it here, +bump TEMPLATE_VERSION, and propagate in each repo's own PR. `just standard-check` +runs `--self-test` below, so the behavioural invariants are asserted rather than +compared as text: three separate repositories cannot diff each other's files, but +each can prove it still behaves the way the standard requires. + +WHY PYTHON RATHER THAN A JUST RECIPE +------------------------------------ +retch established this and it is the more portable mechanism: no `sh`, no +`cygpath`, no coreutils, and nothing from Git's `usr\\bin` on Windows. A `bash` +shebang recipe cannot run on Windows at all without `cygpath`, and a plain `sh` +recipe still needs an `sh` on PATH. This needs only an interpreter that Windows +users of a Rust project already have. + +THE FOUR THINGS THIS GETS RIGHT, EACH MEASURED THE HARD WAY +----------------------------------------------------------- +1. nushell's autoload directory is NOT the XDG one on Windows. `$nu.user-autoload-dirs` + there is exactly `%APPDATA%\\nushell\\autoload`, one entry; nushell does not read + `~/.config/nushell/autoload` at all, whatever XDG_CONFIG_HOME says. Getting it wrong + is SILENT — the installer reports success and delivers nothing. This was found only + because a set of shell aliases turned out to have been missing for months while + existing in the dotfiles the whole time. + +2. zsh reads completion functions ONLY from directories named in `fpath`, and + `~/.local/share/zsh/site-functions` is not on it by default on any distribution. + Printing "auto-loaded" is therefore a lie on such a machine: the file exists, zsh + never reads it, and ` ` produces nothing with no indication why. So this + CHECKS instead of claiming — and it must use an INTERACTIVE zsh, because a + non-interactive one sources neither .zshrc nor anything it includes, so its `fpath` + is the built-in default. Checking the wrong one reported NOT ACTIVE on a machine + where completion worked perfectly: the mirror of the bug it replaced, equally + confident and equally wrong. + +3. A shell whose generation FAILS must fail the whole run. The predecessor logged the + error to stderr, carried on, and then printed "Installed completions for :" + with the full path list regardless — a step reporting success having partly done + nothing, which is the exact failure class these projects exist to refuse. + +4. PowerShell's directory is very likely wrong on Windows and is left as the XDG path + DELIBERATELY. `~/.config/powershell` is right for pwsh on Linux and macOS; on Windows + the profile directory is `Split-Path $PROFILE`, which OneDrive folder-redirection can + move and which no other platform can compute. Measured: `$PROFILE` sat under + `OneDrive\\Documents\\PowerShell` and did not source `~/.config/powershell`, so the + file written there is dead. Replacing a known-harmless wrong answer with a guessed one + is the wrong trade — this wants a design decision, not a substitution. Reported as + NOT ACTIVE below rather than silently claimed. +""" import os -import sys import subprocess +import sys from pathlib import Path -def main(): +TEMPLATE_VERSION = 2 + + +def completion_dirs(env, home): + """Where each shell's completions belong. Pure, so the Windows branch is testable. + + `env` and `home` are arguments rather than read from the process, because a helper + that reaches for its own environment can only be tested on the platform it is run on + — and the one branch that matters most here is the one Unix hosts never take. + """ + xdg_data = Path(env.get("XDG_DATA_HOME") or home / ".local" / "share") + xdg_config = Path(env.get("XDG_CONFIG_HOME") or home / ".config") + appdata = env.get("APPDATA") + + # Invariant 1. Windows nushell reads ONLY %APPDATA%\nushell\autoload. + nu = Path(appdata) / "nushell" / "autoload" if appdata else xdg_config / "nushell" / "autoload" + + return { + "bash": (xdg_data / "bash-completion" / "completions", "{bin}"), + "zsh": (xdg_data / "zsh" / "site-functions", "_{bin}"), + "fish": (xdg_config / "fish" / "completions", "{bin}.fish"), + "elvish": (xdg_config / "elvish" / "lib", "{bin}.elv"), + "nushell": (nu, "50{bin}-completions.nu"), + "power-shell": (xdg_config / "powershell", "{bin}.ps1"), + } + + +def zsh_reads(directory): + """Whether an INTERACTIVE zsh has `directory` on its fpath. + + Returns None when zsh is absent or could not be asked — deliberately distinct from + False, because "not installed" and "installed but will not load this" call for + different messages, and collapsing them would state something nobody measured. + """ + try: + res = subprocess.run( + ["zsh", "-i", "-c", "print -l $fpath"], + capture_output=True, text=True, timeout=20, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + return str(directory) in res.stdout.splitlines() + + +def generate(binary, shell, out_path, repo_root, from_path=False): + """Write one completion file, or raise. Invariant 3: a failure is not survivable. + + `from_path=True` runs the binary as resolved on PATH instead of one built here, which + is what `install-tag` needs: completions must come from the binary that was actually + installed, or they can silently describe a different CLI than the one on the machine. + """ + if from_path: + cmd = [binary] + else: + candidates = [ + repo_root / "target" / "release" / f"{binary}.exe", + repo_root / "target" / "release" / binary, + repo_root / "target" / "debug" / f"{binary}.exe", + repo_root / "target" / "debug" / binary, + ] + exe = next((c for c in candidates if c.exists()), None) + cmd = [str(exe)] if exe else ["cargo", "run", "-q", "--bin", binary, "--"] + res = subprocess.run(cmd + ["--completions", shell], capture_output=True, text=True) + if res.returncode != 0: + raise RuntimeError( + f"generating {shell} completions for {binary} failed " + f"(exit {res.returncode}): {res.stderr.strip() or ''}" + ) + if not res.stdout.strip(): + raise RuntimeError(f"{binary} --completions {shell} produced no output") + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(res.stdout, encoding="utf-8") + + +def self_test(): + """Assert the invariants the standard requires. Run by `just standard-check`. + + This replaces a text diff. Three separate repositories cannot compare each other's + files, but each can prove its vendored copy still behaves correctly — which is the + stronger property anyway, and the one that was actually violated when two repos + quietly shipped the pre-fix nushell path for months. + """ + home = Path("/home/u") + failures = [] + + def check(name, got, want): + if got != want: + failures.append(f" {name}\n expected: {want}\n got: {got}") + + # Invariant 1, the branch Unix hosts never take and therefore never notice. + win = completion_dirs({"APPDATA": r"C:\Users\u\AppData\Roaming"}, home) + check("windows nushell dir", + win["nushell"][0], Path(r"C:\Users\u\AppData\Roaming") / "nushell" / "autoload") + + unix = completion_dirs({}, home) + check("unix nushell dir", unix["nushell"][0], home / ".config" / "nushell" / "autoload") + + # APPDATA must not disturb anything else — a Windows-only fix that moved the other + # five directories would be a fleet-wide regression. + for shell in ("bash", "zsh", "fish", "elvish", "power-shell"): + check(f"{shell} dir unaffected by APPDATA", win[shell][0], unix[shell][0]) + + # XDG overrides still honoured. + over = completion_dirs({"XDG_DATA_HOME": "/x/data", "XDG_CONFIG_HOME": "/x/cfg"}, home) + check("XDG_DATA_HOME honoured", over["zsh"][0], Path("/x/data") / "zsh" / "site-functions") + check("XDG_CONFIG_HOME honoured", over["fish"][0], Path("/x/cfg") / "fish" / "completions") + + # An empty variable is not a location. `os.environ.get` returning "" would otherwise + # resolve every path against the filesystem root. + empty = completion_dirs({"XDG_DATA_HOME": "", "XDG_CONFIG_HOME": ""}, home) + check("empty XDG_DATA_HOME falls back", empty["zsh"][0], unix["zsh"][0]) + + # All six shells present, so a silently dropped one cannot pass. + check("shell count", len(unix), 6) + + if failures: + print(f"self-test FAILED (template v{TEMPLATE_VERSION}):", file=sys.stderr) + print("\n".join(failures), file=sys.stderr) + return 1 + print(f"install_completions.py self-test passed (template v{TEMPLATE_VERSION})") + return 0 + + +def main(argv): + if "--self-test" in argv: + return self_test() + + from_path = "--from-path" in argv + binaries = [a for a in argv if not a.startswith("-")] + if not binaries: + print( + "usage: install_completions.py [binary...] [--from-path] | --self-test", + file=sys.stderr, + ) + return 2 + repo_root = Path(__file__).resolve().parent.parent - - # Locate built retch binary - bin_path = None - candidates = [ - repo_root / "target" / "release" / "retch.exe", - repo_root / "target" / "release" / "retch", - repo_root / "target" / "debug" / "retch.exe", - repo_root / "target" / "debug" / "retch", - ] - for c in candidates: - if c.exists(): - bin_path = c - break - - if not bin_path: - # Fallback to cargo run - cmd_prefix = ["cargo", "run", "-q", "--"] + dirs = completion_dirs(os.environ, Path.home()) + + for binary in binaries: + for shell, (directory, pattern) in dirs.items(): + out = directory / pattern.format(bin=binary) + generate(binary, shell, out, repo_root, from_path) # raises — invariant 3 + src = "the installed binary" if from_path else "this checkout" + print(f"Installed completions for {binary} (from {src})") + + print() + zsh_dir = dirs["zsh"][0] + state = zsh_reads(zsh_dir) + if state is True: + print(f" zsh auto-loaded from {zsh_dir}") + elif state is False: + print(f" zsh NOT ACTIVE -- {zsh_dir} is not on your $fpath.") + print(" The file is written but zsh will never read it. Add this to") + print(" ~/.zshrc BEFORE compinit runs, then restart the shell:") + print() + print(f" fpath+=({zsh_dir})") + print() else: - cmd_prefix = [str(bin_path)] - - # Determine completion target directories - home = Path.home() - xdg_data = Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) - xdg_config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) - - targets = { - "bash": (xdg_data / "bash-completion" / "completions", "retch"), - "zsh": (xdg_data / "zsh" / "site-functions", "_retch"), - "fish": (xdg_config / "fish" / "completions", "retch.fish"), - "elvish": (xdg_config / "elvish" / "lib", "retch.elv"), - "nushell": (xdg_config / "nushell" / "autoload", "50retch-completions.nu"), - "power-shell": (xdg_config / "powershell", "retch.ps1"), - } + print(" zsh not checked -- zsh is not installed, or could not be asked") - for shell_name, (target_dir, filename) in targets.items(): - target_dir.mkdir(parents=True, exist_ok=True) - out_file = target_dir / filename - cmd = cmd_prefix + ["--completions", shell_name] - try: - res = subprocess.run(cmd, capture_output=True, text=True, check=True) - out_file.write_text(res.stdout, encoding="utf-8") - except subprocess.CalledProcessError as e: - print(f"Error generating completions for {shell_name}: {e.stderr}", file=sys.stderr) + print(f" bash source {dirs['bash'][0]}/ (or restart shell)") + print(f" fish auto-loaded from {dirs['fish'][0]}") + print(f" elvish add to rc.elv: eval (slurp < {dirs['elvish'][0]}/.elv)") + print(f" nushell auto-loaded from {dirs['nushell'][0]}") + if os.environ.get("APPDATA"): + print(" powershell NOT ACTIVE on Windows -- $PROFILE is under Documents\\PowerShell") + print(f" (OneDrive may move it) and does not source {dirs['power-shell'][0]}.") + print(" Dot-source the file from your $PROFILE to use it.") + else: + print(f" powershell add to $PROFILE: . {dirs['power-shell'][0]}/.ps1") + print() + print(" Shell aliases do not inherit completions. For an alias, tell your shell they") + print(" are the same command: zsh compdef =") + print(" fish complete -c -w ") + print(" bash complete -o default -F _ ") + return 0 - print("Installed completions for retch:") - for shell_name, (target_dir, filename) in targets.items(): - print(f" {shell_name:12}: {target_dir / filename}") if __name__ == "__main__": - main() + try: + sys.exit(main(sys.argv[1:])) + except RuntimeError as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/install_man.py b/scripts/install_man.py index e9fd028..ead033c 100644 --- a/scripts/install_man.py +++ b/scripts/install_man.py @@ -1,26 +1,127 @@ #!/usr/bin/env python3 -"""Cross-platform installer for retch manual page.""" +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 l1a +"""Install man page(s) to the XDG man directory. Canonical across repos. + +TEMPLATE v2 — vendored verbatim in rusticprofile, retch and etr. Change it here, bump +TEMPLATE_VERSION, and propagate in each repo's own PR. `just standard-check` runs +`--self-test`. + +Python rather than a just recipe for the reason retch established: no `sh`, no `cygpath`, +no POSIX `install(1)`, and nothing from Git's `usr\\bin` on Windows. + +`--from-tag ` reads each page out of that git tag instead of the working tree, which +is what `install-tag` needs. Installing a tag's binary beside the worktree's man page — +a v0.2.22 binary with a v0.2.23 page because the checkout had moved on — is exactly the +kind of individually-plausible mismatch these projects exist to refuse. A page that is +gitignored (etr builds its pages into an ignored directory) is reported as skipped rather +than failing the run, because the binary and completions are still correctly installed and +saying so is more useful than aborting. +""" import os import shutil +import subprocess +import sys from pathlib import Path -def main(): - repo_root = Path(__file__).resolve().parent.parent - src_man = repo_root / "docs" / "retch.1" +TEMPLATE_VERSION = 2 + + +def man_dir(env, home): + """The XDG man directory. Pure, so it is testable without touching the environment.""" + xdg_data = Path(env.get("XDG_DATA_HOME") or home / ".local" / "share") + return xdg_data / "man" + + +def install_from_tree(page, dest_dir): + src = Path(page) + if not src.is_file(): + raise RuntimeError(f"{page} does not exist — run `just man` first") + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / src.name + shutil.copyfile(src, dest) + os.chmod(dest, 0o644) + return dest + + +def install_from_tag(page, dest_dir, tag): + """Read the page out of `tag`. Returns None when it is not tracked there.""" + probe = subprocess.run(["git", "cat-file", "-e", f"{tag}:{page}"], capture_output=True) + if probe.returncode != 0: + return None + res = subprocess.run(["git", "show", f"{tag}:{page}"], capture_output=True) + if res.returncode != 0: + raise RuntimeError(f"git show {tag}:{page} failed: {res.stderr.decode(errors='replace')}") + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / Path(page).name + dest.write_bytes(res.stdout) + os.chmod(dest, 0o644) + return dest + + +def self_test(): + home = Path("/home/u") + failures = [] + + def check(name, got, want): + if got != want: + failures.append(f" {name}\n expected: {want}\n got: {got}") + + check("default man dir", man_dir({}, home), home / ".local" / "share" / "man") + check("XDG_DATA_HOME honoured", man_dir({"XDG_DATA_HOME": "/x/data"}, home), Path("/x/data") / "man") + # An empty variable is not a location — otherwise the page lands at /man. + check("empty XDG_DATA_HOME falls back", man_dir({"XDG_DATA_HOME": ""}, home), + home / ".local" / "share" / "man") + + if failures: + print(f"self-test FAILED (template v{TEMPLATE_VERSION}):", file=sys.stderr) + print("\n".join(failures), file=sys.stderr) + return 1 + print(f"install_man.py self-test passed (template v{TEMPLATE_VERSION})") + return 0 + + +def main(argv): + if "--self-test" in argv: + return self_test() + + tag = None + if "--from-tag" in argv: + i = argv.index("--from-tag") + if i + 1 >= len(argv): + print("error: --from-tag needs a tag", file=sys.stderr) + return 2 + tag = argv[i + 1] + argv = argv[:i] + argv[i + 2:] + + pages = [a for a in argv if not a.startswith("-")] + if not pages: + print("usage: install_man.py [page.1...] [--from-tag TAG] | --self-test", + file=sys.stderr) + return 2 - if not src_man.exists(): - print(f"Error: man page not found at {src_man}. Run 'just man' first.", file=os.sys.stderr) - os.sys.exit(1) + dest_dir = man_dir(os.environ, Path.home()) / "man1" + for page in pages: + if tag: + dest = install_from_tag(page, dest_dir, tag) + if dest is None: + print(f" note: {page} is not tracked at {tag}; left as-is " + f"(run `just install-man` from a checkout at that tag)") + continue + print(f" {dest.name} <- {tag}") + else: + dest = install_from_tree(page, dest_dir) + print(f" {dest.name} <- working tree") - home = Path.home() - xdg_data = Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) - target_dir = xdg_data / "man" / "man1" - target_dir.mkdir(parents=True, exist_ok=True) - dst_man = target_dir / "retch.1" + print(f"Man page(s) installed to {dest_dir}") + print(f" add {dest_dir.parent} to MANPATH if it is not already there") + return 0 - shutil.copy2(src_man, dst_man) - print(f"Man page installed to {dst_man}") if __name__ == "__main__": - main() + try: + sys.exit(main(sys.argv[1:])) + except RuntimeError as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/templates/justfile-common.just b/templates/justfile-common.just new file mode 100644 index 0000000..7698cba --- /dev/null +++ b/templates/justfile-common.just @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 l1a +# +# ============================================================================ +# CANONICAL COMMON JUSTFILE BLOCK — template v2 +# ============================================================================ +# +# The text below `---- BEGIN CANONICAL ----` is the reference for the block +# delimited by `# >>> COMMON (template v2)` and `# <<< COMMON` in a project's +# Justfile. Vendored in: rusticprofile, retch, etr. +# +# It is a THIN layer. The real work lives in two vendored Python helpers, +# `scripts/install_completions.py` and `scripts/install_man.py`, which carry +# their own rationale and their own `--self-test`. +# +# +# WHY PYTHON HELPERS RATHER THAN JUST RECIPES (v2 reversed v1 here) +# ----------------------------------------------------------------- +# This is retch's finding, adopted as the standard. retch converted these exact +# recipes to Python in its v0.6.16 to eliminate bash-shebang escaping bugs and +# run natively on Windows PowerShell, CMD and Unix shells WITHOUT requiring +# Git's `usrin` on PATH. +# +# v1 of this template got that backwards: it took rusticprofile's plain-`sh` +# recipes as canonical because rusticprofile had the CORRECTNESS fixes, and +# would have regressed retch's deliberate portability work in the name of +# consistency. The two repos had each solved half the problem. v2 keeps retch's +# mechanism and rusticprofile's measured correctness: +# +# * a `bash` shebang recipe cannot run on Windows without `cygpath` at all +# * a plain `sh` recipe still needs an `sh` on PATH +# * a Python helper needs only an interpreter, and its logic is unit-testable +# off-platform — which matters because the branch that keeps being wrong is +# the Windows one, on machines that never take it +# +# The lesson worth keeping: "the repo with the fixes" and "the repo with the +# right mechanism" were not the same repo, and picking a reference without +# reading each one's release log would have shipped a regression as a standard. +# +# +# WHY A VENDORED COPY RATHER THAN `just import` +# --------------------------------------------- +# Three separate git repositories. An `import` still needs the imported file to +# exist in each one, so it moves the duplication rather than removing it, and a +# submodule for one file costs more than it saves. What makes a copy safe is not +# that it is a copy — it is that something CHECKS it. +# +# Not hypothetical. rusticprofile measured and fixed three completion defects and +# both siblings then carried them for months, because nothing compared them: +# +# 1. nushell completions written to the XDG path, which Windows nushell never +# reads — live in retch AND etr +# 2. output claiming zsh completions are "auto-loaded from the default $fpath", +# false on every distribution — live in etr +# 3. `install-completions` as a bash shebang recipe, unrunnable on Windows — etr +# +# And retch has a fourth of its own: its helper logs a generation failure to +# stderr, carries on, and then prints "Installed completions for retch:" with +# the full path list regardless — success reported over work not done. +# +# +# WHY `standard-check` RUNS SELF-TESTS RATHER THAN DIFFING TEXT +# ------------------------------------------------------------ +# With the logic in a script, a within-repo text diff checks nothing useful, and +# across repos it is impossible — git cannot diff a file it does not have. So +# each helper asserts its own invariants and `standard-check` runs them. That is +# the stronger property anyway: it is what was actually violated above, and a +# text diff would pass happily on a repo that never adopted the standard at all. +# +# +# WHY EVERY RECIPE IS WRITTEN AGAINST VARIABLES +# --------------------------------------------- +# `etr` ships two binaries (`etr`, `etrs`); the others ship one. A block with a +# hardcoded binary name cannot be copied, and a standard that cannot be copied is +# decoration. Project facts live ABOVE the block, in a PROJECT header each repo +# owns: +# +# BINS := "etr etrs" # space separated +# MAN_PAGES := "man/build/etr.1 man/build/etrs.1" # space separated +# +# Project-specific recipes are normal — put them OUTSIDE the markers, under +# `# ===== PROJECT-SPECIFIC =====`. Editing INSIDE the markers means changing the +# standard: edit this file and both helpers, bump TEMPLATE_VERSION and the marker, +# and propagate to the siblings in their own PRs. +# +# +# SCOPE, STATED SO THE GAPS ARE NOT READ AS OVERSIGHTS +# ---------------------------------------------------- +# Covers the install/man/completions family and `standard-check`. Does NOT cover +# `check`/`lint`/`test`/`pr`/`open-pr`/`merge-pr`: those legitimately differ today +# (`--workspace` in retch, `--all-targets` in etr, bare in rusticprofile) and +# reconciling them is a behaviour change per repo rather than a copy. `man` stays +# project-specific — one repo commits its page, one gitignores it, one builds two. +# +# Known divergences left alone, for whoever extends this: +# * `Justfile` (rusticprofile, retch) vs `justfile` (etr) +# * `lint` (rusticprofile, retch) vs `clippy` (etr) +# * `## Current State (vX)` vs `## Current state: vX` in NOTES.md +# * etr has no `install-hooks` and no `open-pr`, so its pre-push gate is +# installed by nothing and its PR path has no gated call site +# +# ---- BEGIN CANONICAL ---- +# The interpreter is resolved ONCE per line, and a missing one is a hard error. The +# `python3 … 2>/dev/null || python …` idiom is deliberately NOT used: it retries on ANY +# failure, so a real error inside the script gets re-run and reported as if the +# interpreter were the problem. +PY := `command -v python3 || command -v python || echo PYTHON-NOT-FOUND` + +# Install from this checkout: binary, man page(s) and completions. +# +# The dependencies are the point. `cargo install` alone replaces the binary and leaves the +# man page and completions at whatever version last ran their recipe — measured on a host +# whose page was ELEVEN releases stale with nothing reporting it. +install: install-man install-completions + cargo install --path . + +# Install a RELEASED tag: binary, man page(s) and completions, all three FROM THAT TAG. +# +# **It deliberately does NOT depend on `install-man`/`install-completions`**, because those +# work from the checkout. Reusing them would pair a tag's binary with the worktree's man +# page and completions — on a checkout one release ahead, a v0.2.22 binary with a v0.2.23 +# page. Mismatched artefacts that each look fine is the failure class this standard exists +# to remove, so the three sources are made to agree: binary from the tag, completions from +# THE INSTALLED BINARY (`--from-path`), man page from the tag (`--from-tag`). +# +# Never `--path`: on a Syncthing-shared checkout that builds from a directory other +# machines write into. Takes a bare version and normalises a leading `v`. +install-tag VERSION: + #!/usr/bin/env bash + set -euo pipefail + V="{{VERSION}}"; V="${V#v}" + [ -n "$V" ] || { echo "error: install-tag needs a version, e.g. just install-tag 0.2.22" >&2; exit 1; } + git rev-parse -q --verify "refs/tags/v${V}" >/dev/null || { + echo "error: tag v${V} is not in this clone. Run: git fetch --tags" >&2; exit 1; } + REPO=$(git config --get remote.origin.url) + echo "Installing from tag v${V} of ${REPO}" + cargo install --git "$REPO" --tag "v${V}" --locked --force + # POST-CONDITION: cargo prints a replacement line, but only a version query proves which + # binary is on PATH now. + for b in {{BINS}}; do + command -v "$b" >/dev/null 2>&1 || { echo "error: $b is not on PATH after install" >&2; exit 1; } + echo " $b -> $("$b" --version)" + done + "{{PY}}" scripts/install_man.py {{MAN_PAGES}} --from-tag "v${V}" + "{{PY}}" scripts/install_completions.py {{BINS}} --from-path + +# Install the man page(s) to the XDG man directory. +install-man: man + @"{{PY}}" scripts/install_man.py {{MAN_PAGES}} + +# Generate and install shell completions for every binary. +# +# Python rather than a just recipe, which is retch's finding and the more portable +# mechanism: no `sh`, no `cygpath`, no coreutils, nothing from Git's `usr\bin` on Windows. +# A `bash` shebang recipe cannot run on Windows without `cygpath` at all, and even a plain +# `sh` recipe still needs an `sh` on PATH. +install-completions: build + @"{{PY}}" scripts/install_completions.py {{BINS}} + +# Prove the vendored helpers still behave the way the standard requires. +# +# **This runs the helpers' own self-tests rather than diffing text**, and that is the whole +# point: three separate repositories cannot diff each other's files, but each can prove its +# copy still behaves correctly — which is the property that was actually violated when two +# repos quietly shipped the pre-fix nushell path for months. A text diff would also have +# passed happily on a repo that had never adopted the standard at all. +standard-check: + #!/usr/bin/env bash + set -euo pipefail + [ "{{PY}}" != "PYTHON-NOT-FOUND" ] || { echo "error: no python3/python on PATH" >&2; exit 1; } + "{{PY}}" scripts/install_completions.py --self-test + "{{PY}}" scripts/install_man.py --self-test