From 83e5f0c74835fc5af7129507b7999c1892191af0 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:15:59 -0700 Subject: [PATCH] Fix nushell completion path; adopt shared helpers install_completions.py wrote nushell completions to $XDG_CONFIG_HOME/nushell/autoload. On Windows $nu.user-autoload-dirs is exactly %APPDATA%\nushell\autoload and nushell never reads the XDG path, so the helper wrote a real file somewhere nothing consults, printed the path, and delivered nothing. Two more defects in the same helper: it logged a generation failure to stderr, continued, and then printed "Installed completions for retch:" unconditionally -- success reported over work not done; and nothing checked whether zsh would ever load the file (it reads only directories on fpath, and site-functions is not on it by default). It now checks, via an INTERACTIVE zsh, since a non-interactive one reports the built-in default. This repo's MECHANISM was right and is now the standard. v0.6.16 moved these recipes to Python so they run natively on Windows without Git's usr\bin; rusticprofile first proposed replacing them with sh recipes because it held the correctness fixes, which would have regressed that work in the name of consistency. Each repo had solved half the problem. install_completions.py and install_man.py are now vendored byte-identically across retch, rusticprofile and etr, with templates/justfile-common.just as the Justfile block reference. standard-check runs their self-tests -- not a text diff, since separate repos cannot diff each other's files and a diff would pass on a repo that never adopted the standard -- and check depends on it. Also adds install-tag VERSION, which installs a released tag with binary, completions (from the INSTALLED binary) and man page (from the tag) so the three cannot disagree. Assisted-By: Claude Opus 5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- Justfile | 100 ++++++++++-- NOTES.md | 57 ++++++- docs/retch.1 | 2 +- scripts/install_completions.py | 281 +++++++++++++++++++++++++++------ scripts/install_man.py | 131 +++++++++++++-- templates/justfile-common.just | 172 ++++++++++++++++++++ 8 files changed, 667 insertions(+), 80 deletions(-) create mode 100644 templates/justfile-common.just 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