diff --git a/crates/minimal/src/completions.rs b/crates/minimal/src/completions.rs new file mode 100644 index 000000000..65f44c94c --- /dev/null +++ b/crates/minimal/src/completions.rs @@ -0,0 +1,218 @@ +//! `min completions` — the shell integration, printed or installed. +//! +//! Two verbs over one artifact, the registration shim: +//! +//! - [`cmd_print`] writes it to stdout, for `source <(min completions print +//! bash)` and for anyone piping it somewhere of their own choosing. +//! - [`cmd_install`] writes it to the file each shell already looks in, with +//! the bookkeeping that used to live only in `scripts/install.sh` (R9.3): +//! per-shell target path, atomic write, unwritable-dir tolerance, zsh +//! `zcompdump` invalidation. That logic was unreachable for anyone who did +//! not install through `curl | sh`; now the installer calls this instead of +//! carrying its own copy, and reads the paths it prints on stdout — one per +//! line — into its install record. +//! +//! Distinct from `completion.rs`, which is the runtime completion engine the +//! installed shim calls back into. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use clap_complete::Shell; + +/// The shells [`cmd_install`] knows a destination for. +/// +/// Narrower than what [`cmd_print`] accepts: installing needs a file the shell +/// autoloads on its own, and these are the three with a standard, user-level +/// one — the same three `scripts/install.sh` has always written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum InstallShell { + Bash, + Zsh, + Fish, +} + +impl InstallShell { + /// Every installable shell, in the order a bare `install` walks them. + const ALL: [Self; 3] = [Self::Bash, Self::Zsh, Self::Fish]; + + /// The name clap_complete's shell registry knows this shell by. + fn name(self) -> &'static str { + match self { + Self::Bash => "bash", + Self::Zsh => "zsh", + Self::Fish => "fish", + } + } + + /// The file this shell autoloads `min`'s registration from. + /// + /// The three paths `scripts/install.sh` computes (R9.3), resolved by the + /// same rules: `${XDG_DATA_HOME:-$HOME/.local/share}` and + /// `${XDG_CONFIG_HOME:-$HOME/.config}`. Deliberately not + /// [`dirs::data_dir`] — on macOS that is `~/Library/Application Support`, + /// where no shell looks. + fn target(self) -> Result { + Ok(match self { + Self::Bash => { + xdg_home("XDG_DATA_HOME", ".local/share")?.join("bash-completion/completions/min") + } + // `_min`, not `min`: zsh autoloads a function file named for the + // command it completes, underscore-prefixed. + Self::Zsh => xdg_home("XDG_DATA_HOME", ".local/share")?.join("zsh/completions/_min"), + Self::Fish => xdg_home("XDG_CONFIG_HOME", ".config")?.join("fish/completions/min.fish"), + }) + } +} + +/// Print the shell integration for `shell` on stdout. +/// +/// This is a *registration* shim, not a completion table: a dozen lines that +/// teach the shell to ask `min` itself what to complete, rather than the +/// thousand-line static script this used to emit. That indirection is the +/// whole point — session names and IDs only exist at runtime, so a table +/// baked at install time cannot contain them (see `completion.rs`). +/// +/// The shim calls `min` by bare name so it resolves through `PATH` — an +/// upgrade that moves the binary keeps working, and the installer puts its +/// bindir on `PATH` in the same breath as writing this file. +pub fn cmd_print(shell: Shell) -> Result<(), anyhow::Error> { + let shim = render(&shell.to_string())?; + std::io::stdout() + .write_all(&shim) + .context("write shell integration to stdout") +} + +/// Install the shell integration for `shells` (all of [`InstallShell::ALL`] +/// when empty), printing each path actually written on stdout, one per line. +/// +/// Those paths are the contract with `scripts/install.sh`: it records what we +/// print so `--uninstall` can undo it. Nothing else goes to stdout. +/// +/// Best-effort per shell, exactly as the installer was: a shell that could not +/// be installed for is a warning on stderr, not a failure. When this runs at +/// the end of an install the binaries are already correctly in place, and one +/// unwritable shared directory must not make the run look failed. +pub fn cmd_install(shells: &[InstallShell]) -> Result<(), anyhow::Error> { + let targets = if shells.is_empty() { + &InstallShell::ALL[..] + } else { + shells + }; + + let mut stdout = std::io::stdout().lock(); + for shell in targets { + match install_one(*shell) { + Ok(path) => writeln!(stdout, "{}", path.display()) + .context("write installed completion path to stdout")?, + Err(err) => eprintln!("warning: {err:#}"), + } + } + Ok(()) +} + +/// Install one shell's registration, returning the path written. +fn install_one(shell: InstallShell) -> Result { + let target = shell + .target() + .with_context(|| format!("failed to install {} completions", shell.name()))?; + let dir = target + .parent() + .with_context(|| format!("{} has no parent directory", target.display()))?; + let tmp = tmp_sibling(&target); + + // Probe that the directory is writable BEFORE generating anything: these + // are shared, user-owned locations that can pre-exist unwritable (a + // root-owned `~/.config/fish/completions`, say), and there is no point + // rendering a shim we cannot place. The probe file is the temp sibling the + // atomic write needs anyway. + std::fs::create_dir_all(dir) + .and_then(|()| std::fs::File::create(&tmp)) + // The io error itself is deliberately dropped: "Permission denied" on a + // path the user never named is noise next to the directory that needs + // fixing, and the installer has always reported this case that way. + .map_err(|_| { + anyhow::anyhow!( + "failed to install {} completions ({} is not writable)", + shell.name(), + dir.display() + ) + })?; + + let shim = render(shell.name())?; + // Written to the sibling and renamed over the destination, so a completing + // shell never sources a half-written file. + if let Err(err) = std::fs::write(&tmp, &shim).and_then(|()| std::fs::rename(&tmp, &target)) { + let _ = std::fs::remove_file(&tmp); + return Err(anyhow::anyhow!( + "failed to install {} completions ({}: {err})", + shell.name(), + target.display() + )); + } + + if shell == InstallShell::Zsh { + drop_zcompdump(); + } + Ok(target) +} + +/// Render the registration shim for `shell` (a clap_complete shell name). +fn render(shell: &str) -> Result, anyhow::Error> { + let shells = clap_complete::env::Shells::builtins(); + let completer = shells + .completer(shell) + .with_context(|| format!("no shell integration for `{shell}`"))?; + + let mut out = Vec::new(); + completer + .write_registration(crate::COMPLETE_VAR, "min", "min", "min", &mut out) + .context("render shell integration")?; + Ok(out) +} + +/// `.tmp.` — the installer's temp-file name, kept identical so a +/// leftover from either writer looks the same on disk. +fn tmp_sibling(target: &Path) -> PathBuf { + let mut tmp = target.as_os_str().to_owned(); + tmp.push(format!(".tmp.{}", std::process::id())); + PathBuf::from(tmp) +} + +/// Drop a pre-existing `compinit` dump after (re)writing zsh's completion file. +/// +/// compinit's staleness check is only (zsh version, completion-file count), +/// which cannot see `_min` change when the count happens to stay equal — so a +/// dump can keep trusting its stale contents, and "restart your shell" does not +/// fix it. The dump is a pure, regenerable cache, so dropping it just forces a +/// real rescan on the next zsh startup. Best-effort: a dump we cannot remove is +/// not a failed install. +fn drop_zcompdump() { + let Some(dump) = zcompdump_path() else { return }; + if dump.exists() && std::fs::remove_file(&dump).is_ok() { + eprintln!("cleared compinit dump cache {}", dump.display()); + } +} + +/// `${ZDOTDIR:-$HOME}/.zcompdump`, the path zsh's `compinit` caches to. +fn zcompdump_path() -> Option { + let base = env_non_empty("ZDOTDIR").or_else(|| env_non_empty("HOME"))?; + Some(PathBuf::from(base).join(".zcompdump")) +} + +/// `$` when set and non-empty, else `$HOME/` — the `${VAR:-…}` +/// resolution `scripts/install.sh` uses for the completion dirs. +fn xdg_home(var: &str, fallback: &str) -> Result { + if let Some(value) = env_non_empty(var) { + return Ok(PathBuf::from(value)); + } + let home = env_non_empty("HOME").with_context(|| format!("neither ${var} nor $HOME is set"))?; + Ok(PathBuf::from(home).join(fallback)) +} + +/// The value of `var` when it is set to something non-empty. An empty variable +/// counts as unset, matching the shell's `${VAR:-default}`. +fn env_non_empty(var: &str) -> Option { + std::env::var_os(var).filter(|value| !value.is_empty()) +} diff --git a/crates/minimal/src/lib.rs b/crates/minimal/src/lib.rs index 61154d615..b0ea58412 100644 --- a/crates/minimal/src/lib.rs +++ b/crates/minimal/src/lib.rs @@ -13,6 +13,7 @@ mod attach; pub mod autospawn; pub mod client; pub mod completion; +pub mod completions; pub mod config; pub mod diag; pub mod dirs; @@ -137,9 +138,10 @@ pub enum Command { /// Never starts a daemon and never fails: no daemon means no output. #[command(name = completion::COMPLETE_SESSION_STR, hide = true)] CompleteSessionStr(CompleteSessionStrArgs), - /// Generate shell completion script + /// Print or install the shell tab-completion integration #[command( - long_about = "Generate a shell tab-completion script for the min CLI.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(min completions bash)" + visible_alias = "completion", + long_about = "Print or install the shell tab-completion integration for the min CLI.\n\n source <(min completions print bash)\n min completions install" )] Completions(CompletionsArgs), } @@ -557,11 +559,37 @@ pub struct CompleteSessionStrArgs { #[derive(Debug, clap::Args)] pub struct CompletionsArgs { - /// The shell type for a CLI completion script should be printed + #[command(subcommand)] + pub command: CompletionsCommand, +} + +#[derive(Debug, Subcommand)] +pub enum CompletionsCommand { + /// Print the shell integration on stdout + #[command( + long_about = "Print the shell integration for a shell on stdout.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(min completions print bash)" + )] + Print(CompletionsPrintArgs), + /// Write the shell integration into each shell's completion directory + #[command( + long_about = "Write the shell integration into each shell's user-level completion directory,\nand print every path written on stdout, one per line.\n\n bash ${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/min\n zsh ${XDG_DATA_HOME:-~/.local/share}/zsh/completions/_min\n fish ${XDG_CONFIG_HOME:-~/.config}/fish/completions/min.fish\n\nA shell that cannot be installed for (an unwritable shared directory, say) is\na warning on stderr, not a failure." + )] + Install(CompletionsInstallArgs), +} + +#[derive(Debug, Args)] +pub struct CompletionsPrintArgs { + /// The shell to print the integration for #[arg(value_parser)] pub shell: Shell, } +#[derive(Debug, Args)] +pub struct CompletionsInstallArgs { + /// Shells to install for (default: every supported shell) + pub shells: Vec, +} + /// The process-wide trace context, minted once at command dispatch. The /// root span carries its ids into every log line, and the SSH client sends /// the same context to the daemon as a `TRACEPARENT` channel env request — @@ -629,7 +657,10 @@ async fn run_command(cli: Cli) -> Result<(), anyhow::Error> { Some(Command::CompleteSessionStr(args)) => { completion::cmd_complete_session_str(&cli.global_args, args).await } - Some(Command::Completions(CompletionsArgs { shell })) => cmd_completions(shell), + Some(Command::Completions(CompletionsArgs { command })) => match command { + CompletionsCommand::Print(args) => completions::cmd_print(args.shell), + CompletionsCommand::Install(args) => completions::cmd_install(&args.shells), + }, } } @@ -821,45 +852,11 @@ fn confirm(question: &str, default: bool) -> Result { }) } -/// Print the shell integration for `shell` on stdout. -/// -/// This is a *registration* shim, not a completion table: a dozen lines that -/// teach the shell to ask `min` itself what to complete, rather than the -/// thousand-line static script this used to emit. That indirection is the -/// whole point — session names and IDs only exist at runtime, so a table -/// baked at install time cannot contain them (see `completion.rs`). -/// -/// The installed file paths are unchanged, and so is -/// `scripts/install.sh` (R9.3): zsh's shim still opens with `#compdef min`, -/// so it autoloads from an fpath dir as `_min`, and bash's and fish's are -/// still plain source-able files. -/// -/// The shim calls `min` by bare name so it resolves through `PATH` — an -/// upgrade that moves the binary keeps working, and the installer puts its -/// bindir on `PATH` in the same breath as writing this file. -fn cmd_completions(shell: Shell) -> Result<(), anyhow::Error> { - let name = shell.to_string(); - let shells = clap_complete::env::Shells::builtins(); - let completer = shells - .completer(&name) - .with_context(|| format!("no shell integration for `{name}`"))?; - - let mut out = Vec::new(); - completer - .write_registration(COMPLETE_VAR, "min", "min", "min", &mut out) - .context("render shell integration")?; - - std::io::stdout() - .write_all(&out) - .context("write shell integration to stdout")?; - Ok(()) -} - /// The environment variable a shell sets to ask `min` for completions. /// /// clap_complete's default, named here because both ends have to agree: the -/// shim emitted by [`cmd_completions`] sets it, and the `CompleteEnv` call in -/// `main.rs` reads it. +/// shim rendered by [`completions::cmd_print`] sets it, and the `CompleteEnv` +/// call in `main.rs` reads it. pub const COMPLETE_VAR: &str = "COMPLETE"; /// List sessions via the `ListSessions` RPC. diff --git a/crates/minimal/src/main.rs b/crates/minimal/src/main.rs index a5e773ea0..79b5255ba 100644 --- a/crates/minimal/src/main.rs +++ b/crates/minimal/src/main.rs @@ -45,7 +45,13 @@ async fn run() -> ExitCode { let cli = minimal::Cli::parse(); let registry = tracing_subscriber::registry().with(filter); - if matches!(cli.command, Some(minimal::Command::CompleteSessionStr(_))) { + // `completions` joins the completion handler on stderr: its stdout is a + // contract — the shim to `source`, or the installed paths the installer + // records — and a log line landing in it would be read as content. + if matches!( + cli.command, + Some(minimal::Command::CompleteSessionStr(_) | minimal::Command::Completions(_)) + ) { registry .with(fmt::layer().with_writer(std::io::stderr)) .init(); diff --git a/docs/reference/cli-min.md b/docs/reference/cli-min.md index b633c44e4..765ec63e6 100644 --- a/docs/reference/cli-min.md +++ b/docs/reference/cli-min.md @@ -164,14 +164,42 @@ min version Prints CLI and daemon version information. -### `completions` +### `completions` (alias: `completion`) ``` -min completions +min completions print +min completions install [...] ``` -Generates a shell tab-completion script. Supported shells include `bash`, `zsh`, -`elvish`, `fish`. Usage: `source <(min completions bash)`. +`print` writes a shell tab-completion script to stdout. Supported shells +include `bash`, `zsh`, `elvish`, `fish`. Usage: +`source <(min completions print bash)`. + +`install` writes that script into the shell's completion directory instead, +for the three shells with a conventional per-user completion path. With no +`SHELL` argument it installs for all three. + +| Shell | Path | +|-------|------| +| `bash` | `$XDG_DATA_HOME/bash-completion/completions/min` (default `~/.local/share/...`) | +| `zsh` | `$XDG_DATA_HOME/zsh/completions/_min` (default `~/.local/share/...`) | +| `fish` | `$XDG_CONFIG_HOME/fish/completions/min.fish` (default `~/.config/...`) | + +Each file is written atomically — a temporary sibling, then a rename — so a +half-written completion file never reaches a shell. + +`install` prints every path it wrote to stdout, one per line. That is a +contract rather than a convenience: `scripts/install.sh` feeds exactly those +paths into its install record so `uninstall` can remove them, and derives no +paths of its own. The bookkeeping therefore has one implementation, reachable +by everyone rather than only by users who installed via `curl | sh`. + +A completion directory that exists but is not writable — these are shared, +user-owned locations that can pre-exist root-owned — produces a warning on +stderr, not a failure: the other shells still install and the exit status is +still 0. For `zsh`, a stale `compinit` dump is dropped after a (re)install, +since it can otherwise keep trusting its cached contents after the completion +file underneath has changed. What it emits is a short *registration* shim, not a completion table: it teaches the shell to ask `min` itself what to offer. That indirection is what makes diff --git a/scripts/install.sh b/scripts/install.sh index bd008029c..b735ad073 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -820,49 +820,44 @@ end EOF record_generated shell-init-fish "$init_dir/fish.fish" -# R9.3 — tab completions, generated by the just-installed binary itself so -# they always match the installed version, written atomically like any other -# install. A failure here is a warning, not an error: the binaries are already -# correctly installed, and completions regenerate on the next run. +# R9.3 — tab completions, installed by the just-installed binary itself +# (`min completions install `), so they always match the installed +# version and the bookkeeping — target dir per shell, atomic write, +# unwritable-dir tolerance, zsh zcompdump invalidation — has exactly one +# implementation, reachable by everyone rather than only by `curl | sh` users. +# The binary prints every path it wrote on stdout, one per line; that is the +# contract, and those paths are what this records. A failure here is a warning, +# not an error: the binaries are already correctly installed, and completions +# regenerate on the next run. gen_completions() { - _dir="${2%/*}" - _tmp="$2.tmp.$$" - # Probe that the dir exists and is writable BEFORE generating, inside a - # subshell with stderr nulled: the completion dirs are shared, user-owned - # locations that can pre-exist unwritable (e.g. a root-owned - # ~/.config/fish/completions), and a redirection error is reported by the - # shell itself — a plain `2>/dev/null` on the command cannot silence it, - # only the subshell wrapper can. Non-fatal either way: the binaries are - # already correctly installed. - if ! ( mkdir -p "$_dir" && : >"$_tmp" ) 2>/dev/null; then - say " completions: warning: failed to install $1 completions ($_dir is not writable)" - return 0 - fi - if ( "$bindir/min" completions "$1" >"$_tmp" ) 2>/dev/null \ - && [ -s "$_tmp" ] \ - && mv -f "$_tmp" "$2" 2>/dev/null; then - record_generated "completions-$1" "$2" - # A pre-existing compinit dump can keep trusting its stale contents - # after the zsh completion file changes (see zcompdump above) — the - # upgrade path from the pre-rewrite installer hits exactly that, and - # "restart your shell" cannot fix it. Cheap rm, so unconditional on - # every (re)generation; failure is as non-fatal as the rest of R9.3. - if [ "$1" = zsh ] && [ -f "$zcompdump" ]; then - if rm -f "$zcompdump" 2>/dev/null; then - say " completions: cleared compinit dump cache $zcompdump" - fi - fi - else - rm -f "$_tmp" 2>/dev/null || true + _out="$tmpdir/completions.out" + _err="$tmpdir/completions.err" + # One shell per call, so each record row still names the shell it belongs + # to (uninstall walks those rows). The binary exits 0 even when it skipped + # a shell with a warning, so a non-zero status means the binary itself is + # unusable — the one case where its stderr is dropped rather than relayed, + # since an unrunnable `min` produces the shell's noise, not a warning. + if ! "$bindir/min" completions install "$1" >"$_out" 2>"$_err"; then say " completions: warning: could not generate $1 completions (non-fatal)" + return 0 fi + # Its warnings (an unwritable dir) and notices (a dropped compinit dump) + # arrive on stderr, re-emitted here so they read like the rest of the + # installer's output. + while IFS= read -r _line; do + say " completions: $_line" + done <"$_err" + while IFS= read -r _path; do + [ -f "$_path" ] || continue + record_generated "completions-$1" "$_path" + done <"$_out" } if [ -x "$bindir/min" ]; then say " completions: generating for bash, zsh, fish" - gen_completions bash "$bash_comp_dir/min" - gen_completions zsh "$zsh_comp_dir/_min" - gen_completions fish "$fish_comp_dir/min.fish" + gen_completions bash + gen_completions zsh + gen_completions fish else say " completions: skipped ($bindir/min not present)" fi diff --git a/scripts/install_test.sh b/scripts/install_test.sh index 7c02467e4..ca944d7b0 100755 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -40,6 +40,12 @@ check(){ if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (want [$1] got [$2])"; f want_ok() { _m="$1"; shift; if "$@"; then ok "$_m"; else bad "$_m"; fi; } want_err() { _m="$1"; shift; if "$@"; then bad "$_m"; else ok "$_m"; fi; } +# record_has — true when the install record holds a +# row pairing that component with that exact path (columns 1 and 2). +record_has() { + awk -v c="$1" -v p="$2" '$1==c && $2==p {hit=1} END{exit !hit}' "$3" +} + # The harness computes expected hashes with whatever SHA-256 tool the host has. # macOS ships `shasum`, not `sha256sum`, so pick portably (same order the # installer under test uses) — otherwise the macOS lane fails in the harness @@ -60,28 +66,70 @@ BUCKET_HOST="https://mock.invalid/minimal-one" mock="$root/bucket" mkdir -p "$mock/versions/v1" +# write_min_stub [label] — the stand-in for the real binary at every +# point install.sh runs it: `completions install ` (R9.3), `ls` and +# `stop` for the live-session prompt (R5.5). The completions arm implements the +# same contract the real command does — write the shell's file under its +# XDG-derived path, print that path on stdout, warn on stderr and still exit 0 +# when the directory is not writable, drop a stale compinit dump for zsh — +# because the installer now delegates all of it and only reads the printed +# paths. +write_min_stub() { + printf '#!/bin/sh\n# mock min (%s)\n' "${2:-previous release}" >"$1" + cat >>"$1" <<'MOCKEOF' +comp_target() { + case "$1" in + bash) printf '%s/bash-completion/completions/min\n' "${XDG_DATA_HOME:-$HOME/.local/share}" ;; + zsh) printf '%s/zsh/completions/_min\n' "${XDG_DATA_HOME:-$HOME/.local/share}" ;; + fish) printf '%s/fish/completions/min.fish\n' "${XDG_CONFIG_HOME:-$HOME/.config}" ;; + esac +} +comp_install() { + _t="$(comp_target "$1")" + _d="${_t%/*}" + if ! ( mkdir -p "$_d" && : >"$_t.tmp.$$" ) 2>/dev/null; then + echo "warning: failed to install $1 completions ($_d is not writable)" >&2 + return 0 + fi + printf '# mock min completions for %s\n' "$1" >"$_t.tmp.$$" + mv -f "$_t.tmp.$$" "$_t" + printf '%s\n' "$_t" + if [ "$1" = zsh ]; then + _dump="${ZDOTDIR:-$HOME}/.zcompdump" + if [ -f "$_dump" ] && rm -f "$_dump" 2>/dev/null; then + echo "cleared compinit dump cache $_dump" >&2 + fi + fi +} +case "${1:-}" in + completions) + shift + [ "${1:-}" = install ] || { echo "mock min: no such completions verb: ${1:-}" >&2; exit 2; } + shift + [ $# -gt 0 ] || set -- bash zsh fish + for _s in "$@"; do comp_install "$_s"; done + ;; + ls) printf 'session-alpha running\n' ;; + stop) + printf '%s\n' "$*" >>"$HOME/stop.calls" + if [ -f "$HOME/sessions.live" ] && [ "${2:-}" != "--force" ]; then + echo "daemon has active sessions; pass --force to shut down anyway" >&2 + exit 1 + fi + ;; +esac +MOCKEOF + chmod +x "$1" +} + # Platform-diverse artifacts with padded columns and comment/blank lines, to # prove awk field-splitting survives padding (R3.1/R3.3). The `minimal` CLI -# artifacts are runnable sh scripts that answer `completions `, because -# the installer generates completions by executing the installed bin/min +# artifacts are runnable sh scripts that answer `completions install `, +# because the installer installs completions by executing the installed bin/min # (R9.3); the other artifacts stay opaque bodies. printf 'linux-amd64-minimald-body\n' >"$mock/versions/v1/minimald-linux-amd64" -cat >"$mock/versions/v1/minimal-linux-amd64" <<'EOF' -#!/bin/sh -# mock min (linux-amd64) -case "${1:-}" in - completions) printf '# mock min completions for %s\n' "$2" ;; - stop) printf '%s\n' "$*" >>"$HOME/stop.calls" ;; -esac -EOF -cat >"$mock/versions/v1/minimal-darwin-arm64" <<'EOF' -#!/bin/sh -# mock min (darwin-arm64) -case "${1:-}" in - completions) printf '# mock min completions for %s\n' "$2" ;; - stop) printf '%s\n' "$*" >>"$HOME/stop.calls" ;; -esac -EOF +write_min_stub "$mock/versions/v1/minimal-linux-amd64" linux-amd64 +write_min_stub "$mock/versions/v1/minimal-darwin-arm64" darwin-arm64 printf 'darwin-arm64-rootfs-body\n' >"$mock/versions/v1/rootfs-arm64.img" # AppArmor components: noarch text (the loader is a runnable stub here), shipped @@ -498,29 +546,6 @@ want_err "PATH advisory suppressed when bin present (R6.2)" grep -q "is not on y # run once, only on a run that actually replaces a file, and only when it was # already on disk beforehand. -# The `min` an upgrade finds already on disk. It records every `stop` call, -# answers `ls`, and — when the scenario drops a `sessions.live` marker in the -# home — refuses a graceful stop with the real daemon's wording, exactly as -# `min stop` does when sessions are running. -write_min_stub() { - cat >"$1" <<'STUB' -#!/bin/sh -# mock min, previous release -case "${1:-}" in - completions) printf '# mock min completions for %s\n' "$2" ;; - ls) printf 'session-alpha running\n' ;; - stop) - printf '%s\n' "$*" >>"$HOME/stop.calls" - if [ -f "$HOME/sessions.live" ] && [ "${2:-}" != "--force" ]; then - echo "daemon has active sessions; pass --force to shut down anyway" >&2 - exit 1 - fi - ;; -esac -STUB - chmod +x "$1" -} - # Seed with a completed install, then stage the next run as an upgrade # (one stale component) whose on-disk `min` reports live sessions. stage_live_upgrade() { @@ -547,7 +572,7 @@ want_err "up-to-date rerun stops no daemon (R5.5)" test -e "$H8/stop.calls" # still runnable, still able to reach the daemon it started. With no sessions # running the graceful stop succeeds, so nothing is forced and nothing is asked. printf 'stale\n' >"$H8/bin/minimald" -write_min_stub "$H8/bin/min" +write_min_stub "$H8/bin/min" "previous release" run daemonupgrade "$H8" check 0 "$rc" "upgrade exits 0" want_ok "upgrade runs min stop (R5.5)" test -f "$H8/stop.calls" @@ -707,7 +732,7 @@ run shellinit2 "$H7" check 0 "$rc" "shell-init rerun exits 0" check 1 "$(grep -c '>>> minimal >>>' "$H7/.bashrc")" "rerun adds no second rc block (R9.2)" -# Completions, generated by executing the installed mock min (R9.3). +# Completions, installed by executing the installed mock min (R9.3). want_ok "bash completions written for min (R9.3)" \ grep -q "mock min completions for bash" "$H7/xdg-data/bash-completion/completions/min" want_ok "zsh completions written as _min (R9.3)" \ @@ -716,6 +741,37 @@ want_ok "fish completions written (R9.3)" \ grep -q "mock min completions for fish" "$H7/xdg-config/fish/completions/min.fish" want_ok "record lists the generated completions (R9.3/R6.1)" \ grep -q "completions-zsh" "$H7/xdg-state/minimal/installed" +# The record's path column is the path the binary printed, per shell — the +# whole contract of the delegation (R9.3). +want_ok "record row carries the installed zsh path (R9.3)" \ + record_has completions-zsh "$H7/xdg-data/zsh/completions/_min" \ + "$H7/xdg-state/minimal/installed" + +# The record is fed by what the binary PRINTS on stdout, not by a path table +# the installer keeps: a min that installs somewhere the installer never +# derives is still recorded (and so still uninstallable). That is the contract +# between the two halves of R9.3. +cat >"$mock/versions/v1/minimal-odd" <<'EOF' +#!/bin/sh +# mock min that installs completions where it likes, and says so on stdout +[ "${1:-}" = completions ] || exit 0 +mkdir -p "$HOME/odd" +printf '# odd completions for %s\n' "$3" >"$HOME/odd/$3-odd" +printf '%s\n' "$HOME/odd/$3-odd" +EOF +chmod +x "$mock/versions/v1/minimal-odd" +h_odd="$(hash_file "$mock/versions/v1/minimal-odd")" +awk -v h="$h_odd" \ + '$1=="minimal" && $2=="linux" {$5=h; $8="versions/v1/minimal-odd"} {print}' \ + "$root/good-components" >"$mock/versions/v1/components" +H16="$root/h16"; mkdir -p "$H16" +run oddpaths "$H16" +check 0 "$rc" "install exits 0 when min installs completions elsewhere (R9.3)" +want_ok "the path the binary printed is what gets recorded (R9.3)" \ + record_has completions-zsh "$H16/odd/zsh-odd" "$H16/xdg-state/minimal/installed" +want_err "the installer records no path of its own devising (R9.3)" \ + grep -q "bash-completion/completions/min" "$H16/xdg-state/minimal/installed" +cp "$root/good-components" "$mock/versions/v1/components" # restore # Both existing bash rc files are hooked, and user content is preserved. H8="$root/h8"; mkdir -p "$H8" @@ -850,8 +906,9 @@ if [ "$(id -u)" -ne 0 ]; then run compreadonly "$H13" chmod 755 "$H13/xdg-config/fish/completions" # restore for later cleanup check 0 "$rc" "unwritable completion dir is non-fatal (R9.3)" + # The binary warns on stderr; the installer relays it in its own voice. want_ok "warning names the unwritable completion dir (R9.3)" \ - grep -q "failed to install fish completions" "$OUT" + grep -q "completions: warning: failed to install fish completions" "$OUT" want_err "no raw shell error leaks on completion failure (R9.3)" \ grep -qi "permission denied" "$OUT" want_ok "other shells' completions still installed (R9.3)" \