From b4d6b8178ebf81c025112deca31db5d0bfce2601 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Thu, 30 Jul 2026 21:47:12 +0200 Subject: [PATCH 01/23] feat(cli): add shell completion scaffolding Add `aenv completion ` to emit static completion scripts for bash, zsh, and fish through clap_complete. The full command tree is rebuilt from the Cli derive spec, so generated completion cannot drift from the real CLI. Static completion covers top-level and nested subcommands, command aliases, flags, the --output table|json enum, and local path arguments such as the Dockerfile passed to `aenv build`. The command aliases (cn, ls, rm, snap, templates, plus the snapshot and template subcommand aliases) are promoted from hidden `alias` to `visible_alias`. clap_complete only emits visible aliases, and #37 explicitly requires alias completion; the side effect is that the aliases now also appear in `--help`. Dynamic resource completion (sandbox IDs filtered by state, template and snapshot names, the start --cold OCI-ref exception) and the shell-loader/installer wiring are deliberately out of scope here and will follow up. Refs #37 --- Cargo.lock | 14 ++- crates/aenv/Cargo.toml | 1 + crates/aenv/src/commands/completion.rs | 120 +++++++++++++++++++++++++ crates/aenv/src/commands/mod.rs | 1 + crates/aenv/src/commands/snapshot.rs | 2 +- crates/aenv/src/commands/template.rs | 4 +- crates/aenv/src/main.rs | 13 +-- 7 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 crates/aenv/src/commands/completion.rs diff --git a/Cargo.lock b/Cargo.lock index 3582f557..8062ac5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,7 @@ dependencies = [ "bytes", "chrono", "clap", + "clap_complete", "crossterm", "directories", "envd", @@ -1479,6 +1480,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -2073,7 +2083,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -5884,7 +5894,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", diff --git a/crates/aenv/Cargo.toml b/crates/aenv/Cargo.toml index 888a83fb..b74c6e59 100644 --- a/crates/aenv/Cargo.toml +++ b/crates/aenv/Cargo.toml @@ -12,6 +12,7 @@ anyhow = "1" bytes = "1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } +clap_complete = "4" crossterm = "0.28" directories = "5" envd = { path = "../../thirdparty/envd" } diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs new file mode 100644 index 00000000..3c93a7b0 --- /dev/null +++ b/crates/aenv/src/commands/completion.rs @@ -0,0 +1,120 @@ +use anyhow::Result; +use clap::Args as ClapArgs; +use clap::CommandFactory; +use clap::ValueEnum; +use clap_complete::Shell as ClapShell; +use std::io::Write; + +/// Shell to generate completion for. +/// +/// Limited to bash/zsh/fish per issue #37; elvish and powershell are explicit +/// non-goals. +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum Shell { + Bash, + Zsh, + Fish, +} + +impl From for ClapShell { + fn from(shell: Shell) -> Self { + match shell { + Shell::Bash => ClapShell::Bash, + Shell::Zsh => ClapShell::Zsh, + Shell::Fish => ClapShell::Fish, + } + } +} + +#[derive(ClapArgs)] +pub struct Args { + /// Shell to generate completion for. + #[arg(value_enum)] + pub shell: Shell, +} + +/// Generate a static completion script for the requested shell and write it to +/// stdout. The command tree is rebuilt from the live `Cli` derive spec via +/// `crate::Cli::command()` so completion can never drift from the real CLI. +pub fn run(args: Args) -> Result<()> { + let mut cmd = crate::Cli::command(); + let mut out = std::io::stdout(); + clap_complete::generate(ClapShell::from(args.shell), &mut cmd, "aenv", &mut out); + out.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generate_for(shell: Shell) -> String { + let mut cmd = crate::Cli::command(); + let mut buf = Vec::new(); + clap_complete::generate(ClapShell::from(shell), &mut cmd, "aenv", &mut buf); + String::from_utf8(buf).expect("completion output is valid UTF-8") + } + + #[test] + fn bash_has_compdef_or_complete_f() { + let s = generate_for(Shell::Bash); + assert!( + s.contains("complete -F") || s.contains("compdef"), + "bash output should register the binary; got:\n{s}" + ); + } + + #[test] + fn zsh_has_compdef_header() { + let s = generate_for(Shell::Zsh); + assert!( + s.starts_with("#compdef"), + "zsh output should start with a #compdef header; got:\n{s}" + ); + } + + #[test] + fn fish_has_complete_calls() { + let s = generate_for(Shell::Fish); + assert!( + s.contains("complete "), + "fish output should contain `complete` invocations; got:\n{s}" + ); + } + + #[test] + fn includes_alias_cn() { + // `cn` is a visible alias for `connect`; clap_complete emits visible + // aliases, so it must appear in the generated script. (None of the + // canonical command names contain the substring "cn", so its presence is + // a clean signal that aliases flow through.) + let s = generate_for(Shell::Bash); + assert!( + s.contains("cn"), + "completion should reference the `cn` alias for connect; got:\n{s}" + ); + } + + #[test] + fn includes_subcommands() { + // Subcommand groups flow through from the derive spec: `snapshot` + // exposes a `create` child, so both must appear in the script. (`create` + // is unique to `snapshot create` — no top-level command uses it.) + let s = generate_for(Shell::Bash); + assert!( + s.contains("snapshot") && s.contains("create"), + "completion should reference the `snapshot` command and its `create` \ + subcommand; got:\n{s}" + ); + } + + #[test] + fn includes_output_enum_table_json() { + // Proves the `--output` Format ValueEnum (Table/Json) flows through. + let s = generate_for(Shell::Bash); + assert!( + s.contains("table") && s.contains("json"), + "completion should enumerate the --output values (table, json); got:\n{s}" + ); + } +} diff --git a/crates/aenv/src/commands/mod.rs b/crates/aenv/src/commands/mod.rs index 47b54df9..b3de6cc5 100644 --- a/crates/aenv/src/commands/mod.rs +++ b/crates/aenv/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod build; +pub mod completion; pub mod connect; pub mod delete; pub mod download; diff --git a/crates/aenv/src/commands/snapshot.rs b/crates/aenv/src/commands/snapshot.rs index 4935492e..b3426a6a 100644 --- a/crates/aenv/src/commands/snapshot.rs +++ b/crates/aenv/src/commands/snapshot.rs @@ -27,7 +27,7 @@ enum Sub { name: Option, }, /// List persistent snapshots - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List { /// Filter snapshots by source sandbox ID #[arg(long = "sandbox-id")] diff --git a/crates/aenv/src/commands/template.rs b/crates/aenv/src/commands/template.rs index 064b77b5..5d409191 100644 --- a/crates/aenv/src/commands/template.rs +++ b/crates/aenv/src/commands/template.rs @@ -20,13 +20,13 @@ pub struct Args { #[derive(Subcommand)] enum Sub { /// List all templates - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List { #[arg(long, value_enum)] output: Option, }, /// Delete a template by ID or name - #[command(alias = "rm")] + #[command(visible_alias = "rm")] Delete { template: String }, /// Watch a template build until it succeeds or fails Watch { template: String }, diff --git a/crates/aenv/src/main.rs b/crates/aenv/src/main.rs index fa3b21b3..38c1cfef 100644 --- a/crates/aenv/src/main.rs +++ b/crates/aenv/src/main.rs @@ -34,26 +34,28 @@ enum Cmd { /// Download a file from a sandbox Download(commands::download::Args), /// Attach an interactive shell to a running sandbox - #[command(alias = "cn")] + #[command(visible_alias = "cn")] Connect(commands::connect::Args), /// Pause a running sandbox Pause(commands::pause::Args), /// Resume a paused sandbox Resume(commands::resume::Args), /// List sandboxes - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List(commands::list::Args), /// Kill a sandbox - #[command(alias = "rm")] + #[command(visible_alias = "rm")] Delete(commands::delete::Args), /// Set the sandbox expiration (seconds from now) Timeout(commands::timeout::Args), /// Snapshot operations - #[command(alias = "snap")] + #[command(visible_alias = "snap")] Snapshot(commands::snapshot::Args), /// Template operations - #[command(alias = "templates")] + #[command(visible_alias = "templates")] Template(commands::template::Args), + /// Generate shell completion scripts + Completion(commands::completion::Args), } fn main() -> Result<()> { @@ -74,5 +76,6 @@ fn main() -> Result<()> { Cmd::Timeout(a) => commands::timeout::run(a), Cmd::Snapshot(a) => commands::snapshot::run(a), Cmd::Template(a) => commands::template::run(a), + Cmd::Completion(a) => commands::completion::run(a), } } From 25562c46c0b333ec44f9e6bf62a4f1431f204532 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Thu, 30 Jul 2026 22:12:15 +0200 Subject: [PATCH 02/23] fix(cli): harden completion output and tests Address review feedback on the completion command: - Generate the script into an in-memory buffer before writing to stdout. clap_complete's generators panic on write errors (Generator::generate calls .expect), so emitting straight to stdout turned a closed downstream pipe into a panic. A Vec cannot fail, so generation is infallible; only the explicit stdout write can, and it propagates through the Result. - Replace loose substring assertions with bash-specific fragments that encode the relevant context (`aenv,cn)` for the connect alias, `aenv__subcmd__snapshot__subcmd__create` for the nested subcommand, and `compgen -W "table json"` for the --output enum), so a regression can no longer hide behind an incidental substring match. --- crates/aenv/src/commands/completion.rs | 40 +++++++++++++++----------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index 3c93a7b0..7b90ce80 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -38,8 +38,16 @@ pub struct Args { /// `crate::Cli::command()` so completion can never drift from the real CLI. pub fn run(args: Args) -> Result<()> { let mut cmd = crate::Cli::command(); - let mut out = std::io::stdout(); - clap_complete::generate(ClapShell::from(args.shell), &mut cmd, "aenv", &mut out); + // Generate into memory first: clap_complete's generators panic on write + // errors (`Generator::generate` calls `.expect`), so writing straight to + // stdout would turn a closed downstream pipe into a panic. A `Vec` cannot + // fail, so generation is infallible here; only the explicit stdout write + // below can, and it propagates cleanly via `?`. + let mut script = Vec::new(); + clap_complete::generate(ClapShell::from(args.shell), &mut cmd, "aenv", &mut script); + + let mut out = std::io::stdout().lock(); + out.write_all(&script)?; out.flush()?; Ok(()) } @@ -84,37 +92,35 @@ mod tests { #[test] fn includes_alias_cn() { - // `cn` is a visible alias for `connect`; clap_complete emits visible - // aliases, so it must appear in the generated script. (None of the - // canonical command names contain the substring "cn", so its presence is - // a clean signal that aliases flow through.) + // Assert a bash-specific dispatch fragment rather than a bare substring: + // the `aenv,cn)` arm exists only because `cn` is a registered alias for + // the top-level `connect` command. let s = generate_for(Shell::Bash); assert!( - s.contains("cn"), - "completion should reference the `cn` alias for connect; got:\n{s}" + s.contains("aenv,cn)"), + "completion should dispatch the `cn` alias; got:\n{s}" ); } #[test] fn includes_subcommands() { - // Subcommand groups flow through from the derive spec: `snapshot` - // exposes a `create` child, so both must appear in the script. (`create` - // is unique to `snapshot create` — no top-level command uses it.) + // This hierarchical dispatch key exists only when `create` is emitted as + // a child of `snapshot`. let s = generate_for(Shell::Bash); assert!( - s.contains("snapshot") && s.contains("create"), - "completion should reference the `snapshot` command and its `create` \ - subcommand; got:\n{s}" + s.contains("aenv__subcmd__snapshot__subcmd__create"), + "completion should register `snapshot create` as a subcommand; got:\n{s}" ); } #[test] fn includes_output_enum_table_json() { - // Proves the `--output` Format ValueEnum (Table/Json) flows through. + // The `--output` enum is emitted as a bash `compgen -W` word list; this + // fragment exists only for that option's possible values. let s = generate_for(Shell::Bash); assert!( - s.contains("table") && s.contains("json"), - "completion should enumerate the --output values (table, json); got:\n{s}" + s.contains("compgen -W \"table json\""), + "completion should offer the --output values (table, json); got:\n{s}" ); } } From 6a176943caa51c2856a0d98a2cff1b525eed91d1 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Thu, 30 Jul 2026 22:19:55 +0200 Subject: [PATCH 03/23] Update crates/aenv/src/commands/completion.rs Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- crates/aenv/src/commands/completion.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index 7b90ce80..a7deb703 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -47,8 +47,11 @@ pub fn run(args: Args) -> Result<()> { clap_complete::generate(ClapShell::from(args.shell), &mut cmd, "aenv", &mut script); let mut out = std::io::stdout().lock(); - out.write_all(&script)?; - out.flush()?; + if let Err(err) = out.write_all(&script).and_then(|_| out.flush()) { + if err.kind() != std::io::ErrorKind::BrokenPipe { + return Err(err.into()); + } + } Ok(()) } From 81cb641811b8b7b4b4125472a6a4564e0e3933b5 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Thu, 30 Jul 2026 22:29:07 +0200 Subject: [PATCH 04/23] test(cli): validate completion features via Cli metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address further review feedback: the alias, nested-subcommand, and --output enum assertions matched clap_complete's generated bash internals (aenv,cn), (aenv__subcmd__snapshot__subcmd__create), and the exact compgen -W "table json" string), which a compatible clap_complete upgrade could reformat without changing completion behavior. Assert on the Command tree instead — connect's visible aliases, snapshot's create subcommand, and the --output argument's possible values — and keep only the per-shell smoke tests (bash/zsh/fish registration) on the generated output. --- crates/aenv/src/commands/completion.rs | 54 +++++++++++++++++--------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index a7deb703..2f4e601c 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -94,36 +94,52 @@ mod tests { } #[test] - fn includes_alias_cn() { - // Assert a bash-specific dispatch fragment rather than a bare substring: - // the `aenv,cn)` arm exists only because `cn` is a registered alias for - // the top-level `connect` command. - let s = generate_for(Shell::Bash); + fn connect_exposes_cn_alias() { + // Assert on the Command tree, not on clap_complete's generated bash + // dispatch format: the internal `aenv,cn)` / `__subcmd__` naming is an + // implementation detail a compatible clap_complete upgrade could rename + // even though completion still works. If `connect` declares `cn` as a + // visible alias, clap_complete emits it — that contract is ours. + let cmd = crate::Cli::command(); + let connect = cmd + .find_subcommand("connect") + .expect("`connect` command exists"); assert!( - s.contains("aenv,cn)"), - "completion should dispatch the `cn` alias; got:\n{s}" + connect.get_visible_aliases().any(|a| a == "cn"), + "`connect` should declare `cn` as a visible alias" ); } #[test] - fn includes_subcommands() { - // This hierarchical dispatch key exists only when `create` is emitted as - // a child of `snapshot`. - let s = generate_for(Shell::Bash); + fn snapshot_exposes_create_subcommand() { + // See `connect_exposes_cn_alias`: assert on the Command tree, not on + // clap_complete's internal bash helper naming. + let cmd = crate::Cli::command(); + let snapshot = cmd + .find_subcommand("snapshot") + .expect("`snapshot` command exists"); assert!( - s.contains("aenv__subcmd__snapshot__subcmd__create"), - "completion should register `snapshot create` as a subcommand; got:\n{s}" + snapshot.find_subcommand("create").is_some(), + "`snapshot` should expose a `create` subcommand" ); } #[test] - fn includes_output_enum_table_json() { - // The `--output` enum is emitted as a bash `compgen -W` word list; this - // fragment exists only for that option's possible values. - let s = generate_for(Shell::Bash); + fn output_arg_offers_table_and_json() { + // Assert on the argument metadata: the exact `compgen -W "table json"` + // string is clap_complete's bash formatting, which a compatible upgrade + // could change. The possible values are defined on the `--output` arg. + let cmd = crate::Cli::command(); + let list = cmd.find_subcommand("list").expect("`list` command exists"); + let output = list + .get_arguments() + .find(|a| a.get_long() == Some("output")) + .expect("`list` should declare an --output argument"); + let possible = output.get_possible_values(); + let names: Vec<&str> = possible.iter().map(|v| v.get_name()).collect(); assert!( - s.contains("compgen -W \"table json\""), - "completion should offer the --output values (table, json); got:\n{s}" + names.contains(&"table") && names.contains(&"json"), + "--output should offer table and json; got {names:?}" ); } } From be16e9592943a819a6db7a1eb543b876b6737412 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Thu, 30 Jul 2026 22:37:46 +0200 Subject: [PATCH 05/23] test(cli): cover completion write branches and add error context Address review feedback: - Extract the generation/write path into write_completion(shell, &mut W), so run() is a one-liner over locked stdout and the write branches are unit-testable without depending on process-global stdout. Add a FailingWriter stub and cover all three branches: success (Vec), BrokenPipe treated as success (the `aenv completion bash | head` case), and other I/O errors propagating. - Wrap the propagated non-BrokenPipe error with .context("writing completion script to stdout") so it surfaces with actionable context rather than a bare OS error. --- crates/aenv/src/commands/completion.rs | 79 +++++++++++++++++++++----- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index 2f4e601c..a7dfc420 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use anyhow::Result; use clap::Args as ClapArgs; use clap::CommandFactory; @@ -37,22 +38,27 @@ pub struct Args { /// stdout. The command tree is rebuilt from the live `Cli` derive spec via /// `crate::Cli::command()` so completion can never drift from the real CLI. pub fn run(args: Args) -> Result<()> { + write_completion(args.shell, &mut std::io::stdout().lock()) +} + +/// Generate the completion script for `shell` and write it to `out`. +/// +/// Generation goes through an in-memory buffer first: clap_complete's +/// generators panic on write errors (`Generator::generate` calls `.expect`), +/// so writing straight to `out` would turn a closed downstream pipe into a +/// panic. The buffer cannot fail, so generation is infallible; only the +/// explicit write below can. A `BrokenPipe` there (e.g. `aenv completion bash +/// | head`) is normal and treated as success; any other error propagates with +/// context. Split out from `run` so the write branches are unit-testable. +fn write_completion(shell: Shell, out: &mut W) -> Result<()> { let mut cmd = crate::Cli::command(); - // Generate into memory first: clap_complete's generators panic on write - // errors (`Generator::generate` calls `.expect`), so writing straight to - // stdout would turn a closed downstream pipe into a panic. A `Vec` cannot - // fail, so generation is infallible here; only the explicit stdout write - // below can, and it propagates cleanly via `?`. let mut script = Vec::new(); - clap_complete::generate(ClapShell::from(args.shell), &mut cmd, "aenv", &mut script); - - let mut out = std::io::stdout().lock(); - if let Err(err) = out.write_all(&script).and_then(|_| out.flush()) { - if err.kind() != std::io::ErrorKind::BrokenPipe { - return Err(err.into()); - } + clap_complete::generate(ClapShell::from(shell), &mut cmd, "aenv", &mut script); + match out.write_all(&script).and_then(|_| out.flush()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Err(err) => Err(err).context("writing completion script to stdout"), } - Ok(()) } #[cfg(test)] @@ -60,12 +66,24 @@ mod tests { use super::*; fn generate_for(shell: Shell) -> String { - let mut cmd = crate::Cli::command(); let mut buf = Vec::new(); - clap_complete::generate(ClapShell::from(shell), &mut cmd, "aenv", &mut buf); + write_completion(shell, &mut buf).expect("writing to a Vec cannot fail"); String::from_utf8(buf).expect("completion output is valid UTF-8") } + /// Writer that always fails with a configured error kind, for exercising + /// `write_completion`'s error branches. + struct FailingWriter(std::io::ErrorKind); + + impl std::io::Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::from(self.0)) + } + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::from(self.0)) + } + } + #[test] fn bash_has_compdef_or_complete_f() { let s = generate_for(Shell::Bash); @@ -142,4 +160,35 @@ mod tests { "--output should offer table and json; got {names:?}" ); } + + #[test] + fn write_completion_succeeds_into_buffer() { + let mut buf: Vec = Vec::new(); + write_completion(Shell::Bash, &mut buf).expect("Vec write succeeds"); + assert!( + !buf.is_empty(), + "a completion script should have been written" + ); + } + + #[test] + fn broken_pipe_is_treated_as_success() { + // `aenv completion bash | head` closes the pipe early; that must exit + // cleanly rather than error or panic. + let mut out = FailingWriter(std::io::ErrorKind::BrokenPipe); + write_completion(Shell::Bash, &mut out) + .expect("BrokenPipe during completion output should not error"); + } + + #[test] + fn other_io_error_propagates() { + let mut out = FailingWriter(std::io::ErrorKind::Other); + let err = write_completion(Shell::Bash, &mut out) + .expect_err("non-BrokenPipe I/O errors should propagate"); + assert!( + err.to_string() + .contains("writing completion script to stdout"), + "error should carry completion context; got: {err}" + ); + } } From 55124bd229368261aad71a6c52c6a0bd0cbc5771 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Fri, 31 Jul 2026 21:32:41 +0200 Subject: [PATCH 06/23] docs(cli): document shell completion Document the `aenv completion` command added in PR #89: how to generate a bash/zsh/fish script and activate it for the current or future shell sessions. Static CLI surface only; dynamic resource-identifier completion remains a separate follow-up. Co-Authored-By: Claude Opus 4.6 --- docs/src/getting-started/aenv-cli.md | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 53104f5b..3e5292ac 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -274,3 +274,48 @@ aenv snapshot list --sandbox-id The table output includes an `IMAGE REF` column (`-` when no image was published); JSON output includes the optional `imageRef` field. To delete a snapshot, use `aenv template delete ` or `aenv template delete ` — snapshots share the same underlying store as templates and are deleted through the same command. + +--- + +## Shell completion + +`aenv completion ` prints a shell-completion script to stdout. The script is rebuilt from the live CLI definition on every run, so it always matches the installed `aenv` — top-level commands, nested subcommands (e.g. `aenv snapshot create`), visible aliases (`cn`, `ls`, `rm`, `snap`, `templates`), flags, the `--output table|json` enum, and local path arguments such as the Dockerfile passed to `aenv build`. + +Three shells are supported; elvish and powershell are explicit non-goals. + +### Generate a script + +```bash +aenv completion bash +aenv completion zsh +aenv completion fish +``` + +Each command writes the matching script to stdout, so redirect it wherever your shell expects: + +```bash +aenv completion bash > ~/.local/share/aenv-completion.bash +aenv completion zsh > ~/.config/aenv/_aenv +aenv completion fish > ~/.config/fish/completions/aenv.fish +``` + +### Activate it + +For a one-session test, evaluate the script in the current shell: + +```bash +source <(aenv completion bash) # bash +eval "$(aenv completion zsh)" # zsh +aenv completion fish | source # fish +``` + +To make completion persistent, add the matching line to your shell's rc file (`~/.bashrc` or `~/.bash_profile`, `~/.zshrc`, `~/.config/fish/config.fish`) and restart the shell or re-source the file. + +Once loaded, completion covers the static CLI surface: + +```bash +aenv # top-level commands +aenv snapshot # nested subcommands (create, list, ...) +aenv list --output # enum values: table, json +aenv build ./ # local path arguments +``` From f8751d8c4642c8f2f0a5f494b56c53cb1ceefcb2 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sat, 1 Aug 2026 15:57:43 +0200 Subject: [PATCH 07/23] docs(cli): correct shell-completion install paths Point the redirect example at the standard per-user completion directories instead of ad-hoc locations: bash: ~/.local/share/bash-completion/completions/aenv zsh: ~/.local/share/zsh/site-functions/_aenv fish: ~/.config/fish/completions/aenv.fish (unchanged) and document that zsh does not put the site-functions dir on fpath by default, so it must be added before compinit. Addresses review feedback on #89 (paths did not match bash/zsh docs). --- docs/src/getting-started/aenv-cli.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 3e5292ac..6431c0f6 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -291,14 +291,21 @@ aenv completion zsh aenv completion fish ``` -Each command writes the matching script to stdout, so redirect it wherever your shell expects: +Each command writes the matching script to stdout, so redirect it into the standard per-user completion directory for your shell: ```bash -aenv completion bash > ~/.local/share/aenv-completion.bash -aenv completion zsh > ~/.config/aenv/_aenv +aenv completion bash > ~/.local/share/bash-completion/completions/aenv +aenv completion zsh > ~/.local/share/zsh/site-functions/_aenv aenv completion fish > ~/.config/fish/completions/aenv.fish ``` +bash and fish auto-load from these directories (bash sources files named after the command from `~/.local/share/bash-completion/completions/`; fish sources `~/.config/fish/completions/`). zsh autoloads `_cmdname` functions from directories on `fpath` — `~/.local/share/zsh/site-functions` is not on `fpath` by default, so if completion does not load, add it before `compinit` in your `~/.zshrc`: + +```bash +fpath+=(~/.local/share/zsh/site-functions) +autoload -Uz compinit && compinit +``` + ### Activate it For a one-session test, evaluate the script in the current shell: From d13bc787b7d7a32b7141705b587403c28f81f667 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sat, 1 Aug 2026 21:02:25 +0200 Subject: [PATCH 08/23] feat(cli): install shell-completion loaders via the installers Address the CHANGES_REQUESTED item on PR #89 / issue #37: wire regenerating shell-completion loaders into make install-aenv, scripts/install-cli.sh, and scripts/install.sh, and remove them with make uninstall-aenv. The loaders source `aenv completion ` at shell start (fish/zsh-user) or on first aenv (bash lazy-load), so completion always matches the installed binary and never goes stale across upgrades. - scripts/shell-completion.sh: canonical install/uninstall helper (the single source of truth), with --prefix/--user flags and user-vs-system destination selection. - install-cli.sh / install.sh: inline the helper verbatim (these are curl|bash'd standalone) and call it after the binary install. - scripts/check-completion-sync.sh: drift guard enforcing the three copies stay byte-identical; wired into CI and `make check-shell-completion`. - Makefile: install-aenv/uninstall-aenv run the loader setup/teardown (AENV_INSTALL_COMPLETION=0 to skip). - scripts/tests/verify-shell-completion.sh: 7-case functional test including the empty-HOME mode-detection regression. - ci.yml: new shell-scripts job (shellcheck + drift + functional). - docs: note that loaders are now installed/removed automatically. --- .github/workflows/ci.yml | 29 +++ Makefile | 28 +++ docs/src/getting-started/aenv-cli.md | 10 + scripts/check-completion-sync.sh | 52 ++++++ scripts/install-cli.sh | 183 ++++++++++++++++++ scripts/install.sh | 181 ++++++++++++++++++ scripts/shell-completion.sh | 228 +++++++++++++++++++++++ scripts/tests/verify-shell-completion.sh | 152 +++++++++++++++ 8 files changed, 863 insertions(+) create mode 100755 scripts/check-completion-sync.sh create mode 100755 scripts/shell-completion.sh create mode 100755 scripts/tests/verify-shell-completion.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691c6d37..76121649 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,14 @@ on: - "**/Cargo.lock" - "rust-toolchain.toml" - "scripts/install.sh" + - "scripts/install-cli.sh" + - "scripts/shell-completion.sh" + - "scripts/check-completion-sync.sh" - "scripts/run-with-capabilities.sh" - "scripts/tests/setup-ublk-access.sh" - "scripts/tests/verify-capability-runner.sh" - "scripts/tests/verify-install-service.sh" + - "scripts/tests/verify-shell-completion.sh" - ".github/actions/**" - ".github/workflows/ci.yml" push: @@ -24,10 +28,14 @@ on: - "**/Cargo.lock" - "rust-toolchain.toml" - "scripts/install.sh" + - "scripts/install-cli.sh" + - "scripts/shell-completion.sh" + - "scripts/check-completion-sync.sh" - "scripts/run-with-capabilities.sh" - "scripts/tests/setup-ublk-access.sh" - "scripts/tests/verify-capability-runner.sh" - "scripts/tests/verify-install-service.sh" + - "scripts/tests/verify-shell-completion.sh" - ".github/actions/**" - ".github/workflows/ci.yml" @@ -63,3 +71,24 @@ jobs: run: sudo scripts/tests/setup-ublk-access.sh "$(id -un)" "$(id -gn)" - name: Unit tests run: make test-unit PROFILE=debug + + shell-scripts: + # Static + functional checks for the shell-completion installer helpers and + # the standalone installers. No Rust toolchain needed. + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck + - name: Shellcheck + run: | + shellcheck -s bash \ + scripts/shell-completion.sh \ + scripts/check-completion-sync.sh \ + scripts/install-cli.sh \ + scripts/install.sh \ + scripts/tests/verify-shell-completion.sh + - name: Completion-loader drift check + run: bash scripts/check-completion-sync.sh + - name: Completion-loader functional test + run: bash scripts/tests/verify-shell-completion.sh diff --git a/Makefile b/Makefile index 116b30c0..78bc9463 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,11 @@ AENV_INSTALL_DIR := $(AENV_INSTALL_PREFIX)/bin # /usr/local; override to empty (AENV_INSTALL_SUDO=) for a user-local prefix. AENV_INSTALL_SUDO ?= sudo +# Set AENV_INSTALL_COMPLETION=0 to skip shell-completion loader setup on +# install/uninstall. The loaders regenerate completion code from the installed +# aenv at shell start, so they never go stale across upgrades. +AENV_INSTALL_COMPLETION ?= 1 + # Script entrypoints TEST_SCRIPTS_DIR := ./scripts/tests @@ -51,6 +56,7 @@ TARGET_PROFILE_DIR = $${CARGO_TARGET_DIR:-$$(pwd)/target}/$(PROFILE) build-snapshot-image \ build-aenv build-aenv-release install-aenv uninstall-aenv \ build-ublk install-ublk \ + check-shell-completion \ fmt clippy \ mutants coverage \ test test-unit test-integration prepare-agent-test-state test-agent test-agent-integration test-envd test-ublk \ @@ -92,11 +98,33 @@ install-aenv: build-aenv-release $(AENV_INSTALL_SUDO) install -d "$(AENV_INSTALL_DIR)" $(AENV_INSTALL_SUDO) install -m 0755 "$${CARGO_TARGET_DIR:-$$(pwd)/target}/release/aenv" "$(AENV_INSTALL_DIR)/aenv" @echo "Installed aenv to $(AENV_INSTALL_DIR)/aenv" +ifeq ($(AENV_INSTALL_COMPLETION),1) + $(AENV_INSTALL_SUDO) ./scripts/shell-completion.sh install --prefix="$(AENV_INSTALL_PREFIX)" +endif uninstall-aenv: +ifeq ($(AENV_INSTALL_COMPLETION),1) + $(AENV_INSTALL_SUDO) ./scripts/shell-completion.sh uninstall --prefix="$(AENV_INSTALL_PREFIX)" +endif $(AENV_INSTALL_SUDO) rm -f "$(AENV_INSTALL_DIR)/aenv" @echo "Removed $(AENV_INSTALL_DIR)/aenv" +# Verify the shell-completion installer helper and its inlined copies stay in +# sync. Run from CI and before relying on the installers. +check-shell-completion: + bash scripts/check-completion-sync.sh + bash scripts/tests/verify-shell-completion.sh + @if command -v shellcheck >/dev/null 2>&1; then \ + shellcheck -s bash \ + scripts/shell-completion.sh \ + scripts/check-completion-sync.sh \ + scripts/install-cli.sh \ + scripts/install.sh \ + scripts/tests/verify-shell-completion.sh; \ + else \ + echo "shellcheck not installed; skipping shellcheck"; \ + fi + fmt: $(CARGO) fmt --all -- --check diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 6431c0f6..8b52b6c9 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -283,6 +283,16 @@ To delete a snapshot, use `aenv template delete ` or `aenv template Three shells are supported; elvish and powershell are explicit non-goals. +### Installed automatically + +The installers set up **regenerating** completion loaders for you, so the completion is always generated from the currently installed `aenv` and never goes stale across upgrades: + +- `make install-aenv` / `make uninstall-aenv` — installs/removes the loaders (set `AENV_INSTALL_COMPLETION=0` to skip). User-local installs (`AENV_INSTALL_PREFIX=~/.local`) write per-user loaders and an `~/.zshrc` snippet; system installs write under `/share`. +- `scripts/install-cli.sh` — installs loaders into the matching user-local or system directories based on the install location. +- `scripts/install.sh` (full installer) — installs system-wide loaders under `/usr/local/share`. + +After install, open a new shell (or re-source your rc) and the loaders take effect. If you installed with one of the methods above, you can skip the manual steps below — they remain as a fallback for users who skipped the installer or want to customize the location. + ### Generate a script ```bash diff --git a/scripts/check-completion-sync.sh b/scripts/check-completion-sync.sh new file mode 100755 index 00000000..14f1c652 --- /dev/null +++ b/scripts/check-completion-sync.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Verify that the inlined `aenv_completion_install` blocks in the standalone +# installers stay byte-identical to the canonical copy in +# scripts/shell-completion.sh. Run from CI / `make check-shell-completion`. +# +# The canonical helper is a single source of truth; install-cli.sh and +# install.sh cannot source it (they are curl|bash'd as standalone scripts), so +# they inline a verbatim copy bracketed by the marker comments: +# +# # BEGIN aenv_completion_install ... +# ... +# # END aenv_completion_install +# +# This script extracts that block from each file and fails on any divergence. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +canonical="$repo_root/scripts/shell-completion.sh" +cli="$repo_root/scripts/install-cli.sh" +full="$repo_root/scripts/install.sh" + +extract() { + awk '/^# BEGIN aenv_completion_install$/,/^# END aenv_completion_install$/' "$1" +} + +# shellcheck disable=SC2312 +block_canon="$(extract "$canonical")" +if [[ -z "$block_canon" ]]; then + echo "error: could not find aenv_completion_install block in $canonical" >&2 + exit 1 +fi + +rc=0 +for f in "$cli" "$full"; do + # shellcheck disable=SC2312 + block_f="$(extract "$f")" + if [[ -z "$block_f" ]]; then + echo "error: could not find aenv_completion_install block in $f" >&2 + rc=1 + continue + fi + if [[ "$block_canon" != "$block_f" ]]; then + echo "error: aenv_completion_install block in $f differs from $canonical" >&2 + diff -u <(printf '%s\n' "$block_canon") <(printf '%s\n' "$block_f") >&2 || true + rc=1 + fi +done + +if [[ $rc -eq 0 ]]; then + echo "aenv_completion_install blocks in sync." +fi +exit "$rc" diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 18cbb437..dac6d2e4 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -67,6 +67,183 @@ run_privileged() { fi } +# BEGIN aenv_completion_install +# (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, +# scripts/install.sh — verified by scripts/check-completion-sync.sh) + +# Write a single completion loader file. Non-fatal on I/O errors. +# $1 destination path +# $2 file mode (e.g. 0644) +# $3 loader content (single line; the loaders are one-liners by design) +_aenv_cc_put() { + local path="$1" mode="$2" content="$3" + local dir="${path%/*}" + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + if ! printf '%s\n' "$content" > "$path" 2>/dev/null; then + printf 'warn: aenv completion: could not write %s\n' "$path" >&2 + return 0 + fi + chmod "$mode" "$path" 2>/dev/null || true +} + +# Append the regenerating zsh rc-snippet to ~/.zshrc, idempotently. +# $1 rc file path +_aenv_cc_put_zsh_rc() { + local rc="$1" + local dir="${rc%/*}" + if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc"; then + return 0 # already installed + fi + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + # Start the block on its own line only when the rc file is non-empty and + # does not already end with a newline; this avoids leaving a stray blank + # line behind after uninstall. + local leader="" last_byte + if [[ -s "$rc" ]]; then + last_byte=$(tail -c 1 "$rc" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + [[ "$last_byte" == "0a" ]] || leader=$'\n' + fi + if ! { + printf '%s' "$leader" + printf '# >>> aenv completion >>>\n' + printf 'autoload -Uz compinit && compinit\n' + # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash + printf 'eval "$(aenv completion zsh)"\n' + printf '# <<< aenv completion <<<\n' + } >> "$rc" 2>/dev/null; then + printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 + fi +} + +# Generate the static zsh completion from the installed aenv into a +# site-functions dir. Skipped (with a warning) if `aenv` is not on PATH, e.g. +# when installing into a prefix that is not yet on PATH; re-running the +# installer after fixing PATH regenerates it. +# $1 destination _aenv path +_aenv_cc_put_zsh_static() { + local path="$1" + local dir="${path%/*}" + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + if ! command -v aenv >/dev/null 2>&1; then + printf 'warn: aenv completion: aenv not on PATH; skipping static zsh file %s\n' "$path" >&2 + return 0 + fi + if ! aenv completion zsh > "$path" 2>/dev/null; then + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 + return 0 + fi + chmod 0644 "$path" 2>/dev/null || true +} + +# Remove the zsh rc-snippet block from ~/.zshrc. Only acts on a balanced +# marker pair; an unbalanced pair is left untouched to avoid truncating the +# user's rc file. +# $1 rc file path +_aenv_cc_rm_zsh_rc() { + local rc="$1" + [[ -f "$rc" ]] || return 0 + local starts ends tmp + starts=$(grep -c '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || true) + ends=$(grep -c '^# <<< aenv completion <<<$' "$rc" 2>/dev/null || true) + starts="${starts:-0}" + ends="${ends:-0}" + [[ "$starts" =~ ^[0-9]+$ ]] || starts=0 + [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 + [[ "$starts" -gt 0 ]] || return 0 + if [[ "$starts" -ne "$ends" ]]; then + printf 'warn: aenv completion: unbalanced markers in %s; leaving it untouched\n' "$rc" >&2 + return 0 + fi + tmp="$(mktemp)" + if awk ' + /^# >>> aenv completion >>>$/ { skip=1; next } + /^# <<< aenv completion <<<$/ { skip=0; next } + !skip { print } + ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" 2>/dev/null; then + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 +} + +# Install or remove the aenv shell-completion loaders. +# +# aenv_completion_install install [--prefix=

] [--user] +# aenv_completion_install uninstall [--prefix=

] [--user] +# +# Always returns 0 so completion setup never aborts the surrounding binary +# installer; per-shell problems are reported as warnings on stderr. +aenv_completion_install() { + local action="" prefix="" user_mode=0 + while (($#)); do + case "$1" in + install|uninstall) action="$1"; shift ;; + --prefix=*) prefix="${1#--prefix=}"; shift ;; + --user) user_mode=1; shift ;; + *) printf 'warn: aenv completion: ignoring unknown argument %s\n' "$1" >&2; shift ;; + esac + done + + if [[ "$action" != "install" && "$action" != "uninstall" ]]; then + printf 'warn: aenv completion: expected an install or uninstall action; skipping\n' >&2 + return 0 + fi + + # Auto-select user mode for a bare invocation or a prefix under $HOME. + # Guard $HOME: if it is unset/empty, the "$prefix" == "$HOME"/* pattern + # would collapse to "/*" and match any absolute path, and the bare $HOME + # reference would abort under `set -u`. Capture it once, safely. + if [[ $user_mode -eq 0 ]]; then + local home="${HOME:-}" + if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then + user_mode=1 + fi + fi + + local bash_file fish_file zsh_file zsh_kind + if [[ $user_mode -eq 1 ]]; then + bash_file="${HOME}/.local/share/bash-completion/completions/aenv" + fish_file="${HOME}/.config/fish/completions/aenv.fish" + zsh_file="${HOME}/.zshrc" + zsh_kind="rc" + else + bash_file="${prefix}/share/bash-completion/completions/aenv" + fish_file="${prefix}/share/fish/vendor_completions.d/aenv.fish" + zsh_file="${prefix}/share/zsh/site-functions/_aenv" + zsh_kind="static" + fi + + if [[ "$action" == "install" ]]; then + _aenv_cc_put "$bash_file" 0644 'source <(aenv completion bash)' + _aenv_cc_put "$fish_file" 0644 'aenv completion fish | source' + if [[ "$zsh_kind" == "rc" ]]; then + _aenv_cc_put_zsh_rc "$zsh_file" + else + _aenv_cc_put_zsh_static "$zsh_file" + fi + else + rm -f "$bash_file" "$fish_file" 2>/dev/null || true + if [[ "$zsh_kind" == "rc" ]]; then + _aenv_cc_rm_zsh_rc "$zsh_file" + else + rm -f "$zsh_file" 2>/dev/null || true + fi + fi + return 0 +} + +# END aenv_completion_install + if ((${#missing_packages[@]} > 0)); then echo "Installing required commands: ${missing_packages[*]} ..." if [[ "$OS" == "darwin" ]]; then @@ -165,6 +342,12 @@ fi echo "Installed: ${DEST}" +# Install regenerating shell-completion loaders (best-effort; never aborts the +# binary install). INSTALL_DIR/ maps to a prefix of INSTALL_DIR/.., which +# selects user mode (~/.zshrc + per-user completion dirs) when the binary is +# installed under $HOME and system mode (/share) otherwise. +aenv_completion_install install --prefix="${INSTALL_DIR%/*}" + if ! command -v aenv &>/dev/null; then echo "" echo "Note: ${INSTALL_DIR} is not on your PATH." diff --git a/scripts/install.sh b/scripts/install.sh index a2dbce88..0555d920 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -63,6 +63,183 @@ curl_get() { curl -fsSL --retry 5 --retry-delay 10 --retry-max-time 60 "$@" } +# BEGIN aenv_completion_install +# (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, +# scripts/install.sh — verified by scripts/check-completion-sync.sh) + +# Write a single completion loader file. Non-fatal on I/O errors. +# $1 destination path +# $2 file mode (e.g. 0644) +# $3 loader content (single line; the loaders are one-liners by design) +_aenv_cc_put() { + local path="$1" mode="$2" content="$3" + local dir="${path%/*}" + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + if ! printf '%s\n' "$content" > "$path" 2>/dev/null; then + printf 'warn: aenv completion: could not write %s\n' "$path" >&2 + return 0 + fi + chmod "$mode" "$path" 2>/dev/null || true +} + +# Append the regenerating zsh rc-snippet to ~/.zshrc, idempotently. +# $1 rc file path +_aenv_cc_put_zsh_rc() { + local rc="$1" + local dir="${rc%/*}" + if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc"; then + return 0 # already installed + fi + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + # Start the block on its own line only when the rc file is non-empty and + # does not already end with a newline; this avoids leaving a stray blank + # line behind after uninstall. + local leader="" last_byte + if [[ -s "$rc" ]]; then + last_byte=$(tail -c 1 "$rc" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + [[ "$last_byte" == "0a" ]] || leader=$'\n' + fi + if ! { + printf '%s' "$leader" + printf '# >>> aenv completion >>>\n' + printf 'autoload -Uz compinit && compinit\n' + # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash + printf 'eval "$(aenv completion zsh)"\n' + printf '# <<< aenv completion <<<\n' + } >> "$rc" 2>/dev/null; then + printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 + fi +} + +# Generate the static zsh completion from the installed aenv into a +# site-functions dir. Skipped (with a warning) if `aenv` is not on PATH, e.g. +# when installing into a prefix that is not yet on PATH; re-running the +# installer after fixing PATH regenerates it. +# $1 destination _aenv path +_aenv_cc_put_zsh_static() { + local path="$1" + local dir="${path%/*}" + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + if ! command -v aenv >/dev/null 2>&1; then + printf 'warn: aenv completion: aenv not on PATH; skipping static zsh file %s\n' "$path" >&2 + return 0 + fi + if ! aenv completion zsh > "$path" 2>/dev/null; then + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 + return 0 + fi + chmod 0644 "$path" 2>/dev/null || true +} + +# Remove the zsh rc-snippet block from ~/.zshrc. Only acts on a balanced +# marker pair; an unbalanced pair is left untouched to avoid truncating the +# user's rc file. +# $1 rc file path +_aenv_cc_rm_zsh_rc() { + local rc="$1" + [[ -f "$rc" ]] || return 0 + local starts ends tmp + starts=$(grep -c '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || true) + ends=$(grep -c '^# <<< aenv completion <<<$' "$rc" 2>/dev/null || true) + starts="${starts:-0}" + ends="${ends:-0}" + [[ "$starts" =~ ^[0-9]+$ ]] || starts=0 + [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 + [[ "$starts" -gt 0 ]] || return 0 + if [[ "$starts" -ne "$ends" ]]; then + printf 'warn: aenv completion: unbalanced markers in %s; leaving it untouched\n' "$rc" >&2 + return 0 + fi + tmp="$(mktemp)" + if awk ' + /^# >>> aenv completion >>>$/ { skip=1; next } + /^# <<< aenv completion <<<$/ { skip=0; next } + !skip { print } + ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" 2>/dev/null; then + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 +} + +# Install or remove the aenv shell-completion loaders. +# +# aenv_completion_install install [--prefix=

] [--user] +# aenv_completion_install uninstall [--prefix=

] [--user] +# +# Always returns 0 so completion setup never aborts the surrounding binary +# installer; per-shell problems are reported as warnings on stderr. +aenv_completion_install() { + local action="" prefix="" user_mode=0 + while (($#)); do + case "$1" in + install|uninstall) action="$1"; shift ;; + --prefix=*) prefix="${1#--prefix=}"; shift ;; + --user) user_mode=1; shift ;; + *) printf 'warn: aenv completion: ignoring unknown argument %s\n' "$1" >&2; shift ;; + esac + done + + if [[ "$action" != "install" && "$action" != "uninstall" ]]; then + printf 'warn: aenv completion: expected an install or uninstall action; skipping\n' >&2 + return 0 + fi + + # Auto-select user mode for a bare invocation or a prefix under $HOME. + # Guard $HOME: if it is unset/empty, the "$prefix" == "$HOME"/* pattern + # would collapse to "/*" and match any absolute path, and the bare $HOME + # reference would abort under `set -u`. Capture it once, safely. + if [[ $user_mode -eq 0 ]]; then + local home="${HOME:-}" + if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then + user_mode=1 + fi + fi + + local bash_file fish_file zsh_file zsh_kind + if [[ $user_mode -eq 1 ]]; then + bash_file="${HOME}/.local/share/bash-completion/completions/aenv" + fish_file="${HOME}/.config/fish/completions/aenv.fish" + zsh_file="${HOME}/.zshrc" + zsh_kind="rc" + else + bash_file="${prefix}/share/bash-completion/completions/aenv" + fish_file="${prefix}/share/fish/vendor_completions.d/aenv.fish" + zsh_file="${prefix}/share/zsh/site-functions/_aenv" + zsh_kind="static" + fi + + if [[ "$action" == "install" ]]; then + _aenv_cc_put "$bash_file" 0644 'source <(aenv completion bash)' + _aenv_cc_put "$fish_file" 0644 'aenv completion fish | source' + if [[ "$zsh_kind" == "rc" ]]; then + _aenv_cc_put_zsh_rc "$zsh_file" + else + _aenv_cc_put_zsh_static "$zsh_file" + fi + else + rm -f "$bash_file" "$fish_file" 2>/dev/null || true + if [[ "$zsh_kind" == "rc" ]]; then + _aenv_cc_rm_zsh_rc "$zsh_file" + else + rm -f "$zsh_file" 2>/dev/null || true + fi + fi + return 0 +} + +# END aenv_completion_install + missing_packages=() command -v curl >/dev/null 2>&1 || missing_packages+=(curl) command -v jq >/dev/null 2>&1 || missing_packages+=(jq) @@ -170,6 +347,10 @@ echo "Downloading aenv CLI ..." download_release_asset "aenv-linux-${ARCH_TAG}" "$tmp_cli" sudo install -m 0755 "$tmp_cli" "${INSTALL_DIR}/aenv" +# Install regenerating shell-completion loaders (best-effort; never aborts the +# install). System-wide install -> system mode writes /share loaders. +aenv_completion_install install --prefix="${INSTALL_DIR%/*}" + # --------------------------------------------------------------------------- # 2. Install the server # --------------------------------------------------------------------------- diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh new file mode 100755 index 00000000..4f17def8 --- /dev/null +++ b/scripts/shell-completion.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Manage lightweight, regenerating shell-completion loaders for the `aenv` CLI. +# +# The loaders do NOT cache a generated completion script. Instead they invoke +# `aenv completion ` at shell start (fish/zsh-user) or on first `aenv +# ` (bash lazy-loading), so the completion always matches the currently +# installed `aenv` binary and never goes stale when the CLI is upgraded. +# +# Usage: +# ./scripts/shell-completion.sh install [--prefix=

] [--user] +# ./scripts/shell-completion.sh uninstall [--prefix=

] [--user] +# +# --user Force user-local mode: writes under $HOME and appends an +# rc-snippet to ~/.zshrc. +# --prefix=

System mode (writes under

/share) unless

is under +# $HOME, in which case user mode is auto-selected. +# +# When neither flag is given, defaults to user mode so a bare run never +# requires root. All installers pass an explicit flag. +# +# Destinations: +# user bash: ~/.local/share/bash-completion/completions/aenv +# fish: ~/.config/fish/completions/aenv.fish +# zsh: rc-snippet in ~/.zshrc (regenerates every shell start) +# system bash:

/share/bash-completion/completions/aenv +# fish:

/share/fish/vendor_completions.d/aenv.fish +# zsh: static

/share/zsh/site-functions/_aenv (system installs are +# refreshed by re-running the installer, so a one-shot static +# file avoids a root-owned edit of every user's rc) +# +# The `aenv_completion_install` function (and its `_aenv_cc_*` helpers) below +# is the single source of truth. It is inlined verbatim into +# scripts/install-cli.sh and scripts/install.sh; scripts/check-completion-sync.sh +# enforces that the three copies stay byte-identical. +set -euo pipefail + +# BEGIN aenv_completion_install +# (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, +# scripts/install.sh — verified by scripts/check-completion-sync.sh) + +# Write a single completion loader file. Non-fatal on I/O errors. +# $1 destination path +# $2 file mode (e.g. 0644) +# $3 loader content (single line; the loaders are one-liners by design) +_aenv_cc_put() { + local path="$1" mode="$2" content="$3" + local dir="${path%/*}" + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + if ! printf '%s\n' "$content" > "$path" 2>/dev/null; then + printf 'warn: aenv completion: could not write %s\n' "$path" >&2 + return 0 + fi + chmod "$mode" "$path" 2>/dev/null || true +} + +# Append the regenerating zsh rc-snippet to ~/.zshrc, idempotently. +# $1 rc file path +_aenv_cc_put_zsh_rc() { + local rc="$1" + local dir="${rc%/*}" + if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc"; then + return 0 # already installed + fi + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + # Start the block on its own line only when the rc file is non-empty and + # does not already end with a newline; this avoids leaving a stray blank + # line behind after uninstall. + local leader="" last_byte + if [[ -s "$rc" ]]; then + last_byte=$(tail -c 1 "$rc" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + [[ "$last_byte" == "0a" ]] || leader=$'\n' + fi + if ! { + printf '%s' "$leader" + printf '# >>> aenv completion >>>\n' + printf 'autoload -Uz compinit && compinit\n' + # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash + printf 'eval "$(aenv completion zsh)"\n' + printf '# <<< aenv completion <<<\n' + } >> "$rc" 2>/dev/null; then + printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 + fi +} + +# Generate the static zsh completion from the installed aenv into a +# site-functions dir. Skipped (with a warning) if `aenv` is not on PATH, e.g. +# when installing into a prefix that is not yet on PATH; re-running the +# installer after fixing PATH regenerates it. +# $1 destination _aenv path +_aenv_cc_put_zsh_static() { + local path="$1" + local dir="${path%/*}" + if ! mkdir -p "$dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 + return 0 + fi + if ! command -v aenv >/dev/null 2>&1; then + printf 'warn: aenv completion: aenv not on PATH; skipping static zsh file %s\n' "$path" >&2 + return 0 + fi + if ! aenv completion zsh > "$path" 2>/dev/null; then + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 + return 0 + fi + chmod 0644 "$path" 2>/dev/null || true +} + +# Remove the zsh rc-snippet block from ~/.zshrc. Only acts on a balanced +# marker pair; an unbalanced pair is left untouched to avoid truncating the +# user's rc file. +# $1 rc file path +_aenv_cc_rm_zsh_rc() { + local rc="$1" + [[ -f "$rc" ]] || return 0 + local starts ends tmp + starts=$(grep -c '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || true) + ends=$(grep -c '^# <<< aenv completion <<<$' "$rc" 2>/dev/null || true) + starts="${starts:-0}" + ends="${ends:-0}" + [[ "$starts" =~ ^[0-9]+$ ]] || starts=0 + [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 + [[ "$starts" -gt 0 ]] || return 0 + if [[ "$starts" -ne "$ends" ]]; then + printf 'warn: aenv completion: unbalanced markers in %s; leaving it untouched\n' "$rc" >&2 + return 0 + fi + tmp="$(mktemp)" + if awk ' + /^# >>> aenv completion >>>$/ { skip=1; next } + /^# <<< aenv completion <<<$/ { skip=0; next } + !skip { print } + ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" 2>/dev/null; then + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 +} + +# Install or remove the aenv shell-completion loaders. +# +# aenv_completion_install install [--prefix=

] [--user] +# aenv_completion_install uninstall [--prefix=

] [--user] +# +# Always returns 0 so completion setup never aborts the surrounding binary +# installer; per-shell problems are reported as warnings on stderr. +aenv_completion_install() { + local action="" prefix="" user_mode=0 + while (($#)); do + case "$1" in + install|uninstall) action="$1"; shift ;; + --prefix=*) prefix="${1#--prefix=}"; shift ;; + --user) user_mode=1; shift ;; + *) printf 'warn: aenv completion: ignoring unknown argument %s\n' "$1" >&2; shift ;; + esac + done + + if [[ "$action" != "install" && "$action" != "uninstall" ]]; then + printf 'warn: aenv completion: expected an install or uninstall action; skipping\n' >&2 + return 0 + fi + + # Auto-select user mode for a bare invocation or a prefix under $HOME. + # Guard $HOME: if it is unset/empty, the "$prefix" == "$HOME"/* pattern + # would collapse to "/*" and match any absolute path, and the bare $HOME + # reference would abort under `set -u`. Capture it once, safely. + if [[ $user_mode -eq 0 ]]; then + local home="${HOME:-}" + if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then + user_mode=1 + fi + fi + + local bash_file fish_file zsh_file zsh_kind + if [[ $user_mode -eq 1 ]]; then + bash_file="${HOME}/.local/share/bash-completion/completions/aenv" + fish_file="${HOME}/.config/fish/completions/aenv.fish" + zsh_file="${HOME}/.zshrc" + zsh_kind="rc" + else + bash_file="${prefix}/share/bash-completion/completions/aenv" + fish_file="${prefix}/share/fish/vendor_completions.d/aenv.fish" + zsh_file="${prefix}/share/zsh/site-functions/_aenv" + zsh_kind="static" + fi + + if [[ "$action" == "install" ]]; then + _aenv_cc_put "$bash_file" 0644 'source <(aenv completion bash)' + _aenv_cc_put "$fish_file" 0644 'aenv completion fish | source' + if [[ "$zsh_kind" == "rc" ]]; then + _aenv_cc_put_zsh_rc "$zsh_file" + else + _aenv_cc_put_zsh_static "$zsh_file" + fi + else + rm -f "$bash_file" "$fish_file" 2>/dev/null || true + if [[ "$zsh_kind" == "rc" ]]; then + _aenv_cc_rm_zsh_rc "$zsh_file" + else + rm -f "$zsh_file" 2>/dev/null || true + fi + fi + return 0 +} + +# END aenv_completion_install + +usage() { + cat <<'EOF' +Usage: shell-completion.sh [--prefix=

] [--user] + +Install or remove regenerating shell-completion loaders (bash, zsh, fish) for +the aenv CLI. See the header comment for destination details. +EOF +} + +if [[ $# -lt 1 ]]; then + usage >&2 + exit 2 +fi + +aenv_completion_install "$@" diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh new file mode 100755 index 00000000..af34f279 --- /dev/null +++ b/scripts/tests/verify-shell-completion.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Functional test for the aenv shell-completion loader installer. +# +# Does not require a real aenv binary: a stub `aenv` is placed on PATH so the +# static-zsh generation path is exercised end-to-end. Run via +# `make check-shell-completion` or directly with `bash scripts/tests/verify-shell-completion.sh`. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_root/scripts/shell-completion.sh" + +tmp_root="$(mktemp -d)" +trap 'rm -rf "$tmp_root"' EXIT + +fake_home="$tmp_root/home" +fake_bin="$tmp_root/bin" +sys_prefix="$tmp_root/sys" +mkdir -p "$fake_home" "$fake_bin" "$sys_prefix" + +# Stub aenv so `aenv completion ` succeeds during static-zsh generation. +cat > "$fake_bin/aenv" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + completion) echo "# fake aenv completion for ${2:-?}" ;; + *) echo "fake aenv" ;; +esac +EOF +chmod +x "$fake_bin/aenv" +export PATH="$fake_bin:$PATH" + +fail() { echo "FAIL: $*" >&2; exit 1; } +assert_contains() { # file needle + [[ -f "$1" ]] || fail "expected file $1 to exist" + grep -q -- "$2" "$1" || fail "expected $1 to contain: $2" +} +assert_absent() { # path + [[ ! -e "$1" ]] || fail "expected $1 to be absent, but it exists" +} +assert_rc_clean() { # rc-file + [[ ! -f "$1" ]] || ! grep -q '^# >>> aenv completion >>>$' "$1" \ + || fail "expected no aenv marker block in $1" +} +marker_count() { # rc-file -> count + if [[ -f "$1" ]]; then + grep -c '^# >>> aenv completion >>>$' "$1" || true + else + echo 0 + fi +} + +# --------------------------------------------------------------------------- +# Test 1: user mode +# --------------------------------------------------------------------------- +echo "==> user-mode install" +HOME="$fake_home" bash "$helper" install --user +bash_file="$fake_home/.local/share/bash-completion/completions/aenv" +fish_file="$fake_home/.config/fish/completions/aenv.fish" +zshrc="$fake_home/.zshrc" +assert_contains "$bash_file" 'source <(aenv completion bash)' +assert_contains "$fish_file" 'aenv completion fish | source' +# shellcheck disable=SC2016 # searching for a literal $(...) string in the rc +assert_contains "$zshrc" 'eval "$(aenv completion zsh)"' +[[ "$(marker_count "$zshrc")" == "1" ]] || fail "expected exactly one marker block after install" + +echo "==> user-mode install is idempotent" +HOME="$fake_home" bash "$helper" install --user +[[ "$(marker_count "$zshrc")" == "1" ]] || fail "re-install appended a duplicate marker block" + +echo "==> user-mode uninstall" +HOME="$fake_home" bash "$helper" uninstall --user +assert_absent "$bash_file" +assert_absent "$fish_file" +assert_rc_clean "$zshrc" + +# --------------------------------------------------------------------------- +# Test 2: system mode +# --------------------------------------------------------------------------- +echo "==> system-mode install" +HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" +sys_bash="$sys_prefix/share/bash-completion/completions/aenv" +sys_fish="$sys_prefix/share/fish/vendor_completions.d/aenv.fish" +sys_zsh="$sys_prefix/share/zsh/site-functions/_aenv" +assert_contains "$sys_bash" 'source <(aenv completion bash)' +assert_contains "$sys_fish" 'aenv completion fish | source' +assert_contains "$sys_zsh" '# fake aenv completion for zsh' +assert_rc_clean "$zshrc" # system mode must NOT edit the user rc + +echo "==> system-mode uninstall" +HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" +assert_absent "$sys_bash" +assert_absent "$sys_fish" +assert_absent "$sys_zsh" + +# --------------------------------------------------------------------------- +# Test 3: auto-detection from a prefix under $HOME behaves like user mode +# --------------------------------------------------------------------------- +echo "==> prefix-under-HOME selects user mode" +HOME="$fake_home" bash "$helper" install --prefix="$fake_home/.local" +assert_contains "$fake_home/.local/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' +# shellcheck disable=SC2016 # searching for a literal $(...) string in the rc +assert_contains "$zshrc" 'eval "$(aenv completion zsh)"' # rc-snippet, not a static file +assert_absent "$fake_home/.local/share/zsh/site-functions/_aenv" # no static file in user mode +HOME="$fake_home" bash "$helper" uninstall --prefix="$fake_home/.local" +assert_absent "$fake_home/.local/share/bash-completion/completions/aenv" +assert_rc_clean "$zshrc" + +# --------------------------------------------------------------------------- +# Test 4: uninstall is a no-op when nothing is installed (and never fails) +# --------------------------------------------------------------------------- +echo "==> uninstall on a clean tree is a no-op" +HOME="$fake_home" bash "$helper" uninstall --user +HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" + +# --------------------------------------------------------------------------- +# Test 5: an unbalanced marker block is left untouched (never truncate a rc) +# --------------------------------------------------------------------------- +echo "==> unbalanced markers are left untouched on uninstall" +malformed="$fake_home/.zshrc" +printf 'user-line-before\n# >>> aenv completion >>>\nautoload -Uz compinit\nuser-line-after\n' > "$malformed" +HOME="$fake_home" bash "$helper" uninstall --user 2>/dev/null +# Nothing is removed: both user lines and the orphan start marker remain. +assert_contains "$malformed" 'user-line-before' +assert_contains "$malformed" 'user-line-after' +assert_contains "$malformed" '# >>> aenv completion >>>' +rm -f "$malformed" + +# --------------------------------------------------------------------------- +# Test 6: system mode skips the static zsh file when aenv is not on PATH but +# still writes the bash/fish stubs (graceful degradation, non-fatal). +# --------------------------------------------------------------------------- +echo "==> system mode without aenv on PATH skips only the static zsh file" +# A PATH that contains neither the fake aenv nor any other aenv. +HOME="$fake_home" PATH="/usr/bin:/bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null +assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' +assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'aenv completion fish | source' +assert_absent "$sys_prefix/share/zsh/site-functions/_aenv" +HOME="$fake_home" PATH="/usr/bin:/bin" bash "$helper" uninstall --prefix="$sys_prefix" +assert_absent "$sys_prefix/share/bash-completion/completions/aenv" + +# --------------------------------------------------------------------------- +# Test 7: a missing $HOME must not abort the helper (set -u) and must not make +# an absolute system prefix match the "$prefix" == "$HOME"/* glob. Regression +# guard for the empty-HOME mode-detection bug. +# --------------------------------------------------------------------------- +echo "==> missing HOME with system prefix stays in system mode and does not abort" +env -u HOME bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 \ + || fail "helper aborted under set -u when HOME is unset" +assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' +env -u HOME bash "$helper" uninstall --prefix="$sys_prefix" >/dev/null 2>&1 +assert_absent "$sys_prefix/share/bash-completion/completions/aenv" + +echo "==> all shell-completion checks passed" From 205223d5a2d21f2509f3e495f80853794176ec22 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sat, 1 Aug 2026 21:12:48 +0200 Subject: [PATCH 09/23] Update .github/workflows/ci.yml Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76121649..1afd680d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: # Static + functional checks for the shell-completion installer helpers and # the standalone installers. No Rust toolchain needed. runs-on: ubuntu-22.04 + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Install shellcheck From 08be532e49865bab8e8dc6d98f5dfa0a9332933b Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sat, 1 Aug 2026 21:13:03 +0200 Subject: [PATCH 10/23] Update .github/workflows/ci.yml Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1afd680d..c3954a78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,8 @@ jobs: # the standalone installers. No Rust toolchain needed. runs-on: ubuntu-22.04 permissions: - contents: read + runs-on: ubuntu-22.04 + timeout-minutes: 15 steps: - uses: actions/checkout@v6 - name: Install shellcheck From eb4bbaf5b129cd42c28cb0efdaf9a240e2eb19d2 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sat, 1 Aug 2026 21:50:55 +0200 Subject: [PATCH 11/23] fix(cli): harden shell-completion installer per review Address OpenCodeReview findings (bug/security/test) on the completion loader wiring, plus fix the broken CI job introduced by an applied suggestion (duplicate runs-on + empty permissions). Canonical helper (scripts/shell-completion.sh, inlined verbatim into install-cli.sh and install.sh): - Marker removal now does single-pass structural validation: reject an end-without-start, nested start, or unterminated start at EOF, and leave the rc untouched on any malformation (previously equal marker counts could let a stray start marker delete rc content through EOF). - Install idempotency requires a complete well-formed block; a partial block warns instead of leaving the user stuck or duplicating. - Static zsh generation is atomic: generate into a temp file in the dest dir and rename on success, so a failure never truncates an existing valid _aenv. - Static zsh generation prefers the just-installed ${prefix}/bin/aenv over whatever aenv is first on PATH (avoids stale/skipped generation). - Capture HOME once (${HOME:-}) and guard user-mode destination paths so an unset HOME with --user/a bare invocation warns and skips instead of aborting under set -u. - Rewrite the rc in place (cat onto it) on removal to preserve its inode, mode, and symlink target rather than replacing the link. CI: fix the shell-scripts job (single runs-on, timeout-minutes: 15, permissions: contents: read). Drift checker: assert exactly one well-ordered BEGIN..END pair per file and compare byte-preserving temp files with cmp. Tests: hermetic no-aenv PATH; failing `aenv completion zsh` leaves the existing _aenv intact with no temp leftovers; unrelated rc content survives install+uninstall; --user with unset HOME warns and does not abort. --- .github/workflows/ci.yml | 4 +- scripts/check-completion-sync.sh | 50 +++++++--- scripts/install-cli.sh | 114 +++++++++++++++-------- scripts/install.sh | 114 +++++++++++++++-------- scripts/shell-completion.sh | 114 +++++++++++++++-------- scripts/tests/verify-shell-completion.sh | 58 +++++++++++- 6 files changed, 311 insertions(+), 143 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3954a78..58b6eb3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,9 @@ jobs: # Static + functional checks for the shell-completion installer helpers and # the standalone installers. No Rust toolchain needed. runs-on: ubuntu-22.04 - permissions: - runs-on: ubuntu-22.04 timeout-minutes: 15 + permissions: + contents: read steps: - uses: actions/checkout@v6 - name: Install shellcheck diff --git a/scripts/check-completion-sync.sh b/scripts/check-completion-sync.sh index 14f1c652..87647b47 100755 --- a/scripts/check-completion-sync.sh +++ b/scripts/check-completion-sync.sh @@ -1,17 +1,20 @@ #!/usr/bin/env bash # Verify that the inlined `aenv_completion_install` blocks in the standalone -# installers stay byte-identical to the canonical copy in -# scripts/shell-completion.sh. Run from CI / `make check-shell-completion`. +# installers stay in sync with the canonical copy in scripts/shell-completion.sh. +# Run from CI / `make check-shell-completion`. # # The canonical helper is a single source of truth; install-cli.sh and # install.sh cannot source it (they are curl|bash'd as standalone scripts), so # they inline a verbatim copy bracketed by the marker comments: # -# # BEGIN aenv_completion_install ... +# # BEGIN aenv_completion_install # ... # # END aenv_completion_install # -# This script extracts that block from each file and fails on any divergence. +# This script extracts that block from each file into a byte-preserving temp +# file (so trailing-newline differences are not silently normalized), validates +# each file has exactly one well-ordered BEGIN..END pair, and fails on any +# divergence. "In sync" means byte-identical block content across the files. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -19,29 +22,46 @@ canonical="$repo_root/scripts/shell-completion.sh" cli="$repo_root/scripts/install-cli.sh" full="$repo_root/scripts/install.sh" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +# extract +# Copies the BEGIN..END block (inclusive) to . Fails (exits 1) if the +# markers are absent, duplicated, or reversed. extract() { - awk '/^# BEGIN aenv_completion_install$/,/^# END aenv_completion_install$/' "$1" + local input="$1" out="$2" + local begins ends first_begin last_end + begins=$(grep -c '^# BEGIN aenv_completion_install$' "$input" || true) + ends=$(grep -c '^# END aenv_completion_install$' "$input" || true) + begins=${begins:-0}; ends=${ends:-0} + [[ "$begins" =~ ^[0-9]+$ ]] || begins=0 + [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 + if [[ "$begins" -ne 1 || "$ends" -ne 1 ]]; then + echo "error: expected exactly one BEGIN and one END marker in $input (found $begins BEGIN, $ends END)" >&2 + return 1 + fi + first_begin=$(grep -n '^# BEGIN aenv_completion_install$' "$input" | cut -d: -f1) + last_end=$(grep -n '^# END aenv_completion_install$' "$input" | cut -d: -f1) + if [[ "$first_begin" -gt "$last_end" ]]; then + echo "error: END marker precedes BEGIN marker in $input" >&2 + return 1 + fi + sed -n "${first_begin},${last_end}p" "$input" > "$out" } -# shellcheck disable=SC2312 -block_canon="$(extract "$canonical")" -if [[ -z "$block_canon" ]]; then - echo "error: could not find aenv_completion_install block in $canonical" >&2 +if ! extract "$canonical" "$tmp_dir/canonical"; then exit 1 fi rc=0 for f in "$cli" "$full"; do - # shellcheck disable=SC2312 - block_f="$(extract "$f")" - if [[ -z "$block_f" ]]; then - echo "error: could not find aenv_completion_install block in $f" >&2 + if ! extract "$f" "$tmp_dir/cand"; then rc=1 continue fi - if [[ "$block_canon" != "$block_f" ]]; then + if ! cmp -s "$tmp_dir/canonical" "$tmp_dir/cand"; then echo "error: aenv_completion_install block in $f differs from $canonical" >&2 - diff -u <(printf '%s\n' "$block_canon") <(printf '%s\n' "$block_f") >&2 || true + diff -u "$tmp_dir/canonical" "$tmp_dir/cand" >&2 || true rc=1 fi done diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index dac6d2e4..541a6743 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -89,13 +89,33 @@ _aenv_cc_put() { chmod "$mode" "$path" 2>/dev/null || true } -# Append the regenerating zsh rc-snippet to ~/.zshrc, idempotently. +# Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: +# strictly alternating start/end pairs with no nesting, reordering, or +# unterminated start at EOF. Returns 1 otherwise. Used to gate both install +# idempotency and removal so a corrupted/partial block is never silently +# truncated and never auto-repaired at the cost of unrelated rc content. +_aenv_cc_rc_well_formed() { + awk ' + BEGIN { in_block = 0 } + /^# >>> aenv completion >>>$/ { if (in_block) exit 1; in_block = 1; next } + /^# <<< aenv completion <<<$/ { if (!in_block) exit 1; in_block = 0; next } + END { if (in_block) exit 1 } + ' "$1" 2>/dev/null +} + +# Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed +# block already present => no-op. A partial/corrupted block => warn and leave it +# for the user (auto-repair could delete unrelated rc lines). No block => append. # $1 rc file path _aenv_cc_put_zsh_rc() { local rc="$1" local dir="${rc%/*}" - if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc"; then - return 0 # already installed + if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null; then + if _aenv_cc_rc_well_formed "$rc"; then + return 0 # idempotent: a complete managed block already exists + fi + printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + return 0 fi if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 @@ -121,55 +141,62 @@ _aenv_cc_put_zsh_rc() { fi } -# Generate the static zsh completion from the installed aenv into a -# site-functions dir. Skipped (with a warning) if `aenv` is not on PATH, e.g. -# when installing into a prefix that is not yet on PATH; re-running the -# installer after fixing PATH regenerates it. +# Generate the static zsh completion into a site-functions dir. $2 is the +# just-installed aenv binary (preferred over whatever is on PATH, which may be +# stale or absent). Generation goes to a temp file in the destination dir and +# is atomically renamed on success, so a failure never truncates an existing +# valid completion file. # $1 destination _aenv path +# $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { - local path="$1" + local path="$1" aenv_bin="${2:-aenv}" local dir="${path%/*}" if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - if ! command -v aenv >/dev/null 2>&1; then - printf 'warn: aenv completion: aenv not on PATH; skipping static zsh file %s\n' "$path" >&2 + local gen=() + if [[ -x "$aenv_bin" ]]; then + gen=("$aenv_bin" completion zsh) + elif command -v aenv >/dev/null 2>&1; then + gen=(aenv completion zsh) + else + printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 return 0 fi - if ! aenv completion zsh > "$path" 2>/dev/null; then - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 - return 0 + local tmp + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp)" + if "${gen[@]}" > "$tmp" 2>/dev/null; then + chmod 0644 "$tmp" 2>/dev/null || true + if mv -f "$tmp" "$path" 2>/dev/null; then + return 0 + fi fi - chmod 0644 "$path" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 } -# Remove the zsh rc-snippet block from ~/.zshrc. Only acts on a balanced -# marker pair; an unbalanced pair is left untouched to avoid truncating the -# user's rc file. +# Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a +# file with a malformed (partial/nested/reordered) block. The rewrite is done +# in place (cat onto the rc) so the rc's inode, mode, ownership, and — for a +# symlinked rc — the link target are preserved. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" [[ -f "$rc" ]] || return 0 - local starts ends tmp - starts=$(grep -c '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || true) - ends=$(grep -c '^# <<< aenv completion <<<$' "$rc" 2>/dev/null || true) - starts="${starts:-0}" - ends="${ends:-0}" - [[ "$starts" =~ ^[0-9]+$ ]] || starts=0 - [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 - [[ "$starts" -gt 0 ]] || return 0 - if [[ "$starts" -ne "$ends" ]]; then - printf 'warn: aenv completion: unbalanced markers in %s; leaving it untouched\n' "$rc" >&2 + grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || return 0 + if ! _aenv_cc_rc_well_formed "$rc"; then + printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi + local tmp tmp="$(mktemp)" - if awk ' - /^# >>> aenv completion >>>$/ { skip=1; next } - /^# <<< aenv completion <<<$/ { skip=0; next } - !skip { print } - ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" 2>/dev/null; then + awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null + # In-place rewrite preserves inode/mode/ownership and writes through a + # symlinked rc rather than replacing the link itself. + if cat "$tmp" > "$rc" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null || true return 0 fi rm -f "$tmp" 2>/dev/null || true @@ -199,22 +226,27 @@ aenv_completion_install() { return 0 fi + # Capture $HOME once, safely (set -u safe). It drives both mode detection + # and the user-mode destination paths, and must not be dereferenced bare. + local home="${HOME:-}" + # Auto-select user mode for a bare invocation or a prefix under $HOME. - # Guard $HOME: if it is unset/empty, the "$prefix" == "$HOME"/* pattern - # would collapse to "/*" and match any absolute path, and the bare $HOME - # reference would abort under `set -u`. Capture it once, safely. if [[ $user_mode -eq 0 ]]; then - local home="${HOME:-}" if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then user_mode=1 fi fi + if [[ $user_mode -eq 1 && -z "$home" ]]; then + printf 'warn: aenv completion: user mode requested but HOME is unset; skipping\n' >&2 + return 0 + fi + local bash_file fish_file zsh_file zsh_kind if [[ $user_mode -eq 1 ]]; then - bash_file="${HOME}/.local/share/bash-completion/completions/aenv" - fish_file="${HOME}/.config/fish/completions/aenv.fish" - zsh_file="${HOME}/.zshrc" + bash_file="${home}/.local/share/bash-completion/completions/aenv" + fish_file="${home}/.config/fish/completions/aenv.fish" + zsh_file="${home}/.zshrc" zsh_kind="rc" else bash_file="${prefix}/share/bash-completion/completions/aenv" @@ -229,7 +261,7 @@ aenv_completion_install() { if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_put_zsh_rc "$zsh_file" else - _aenv_cc_put_zsh_static "$zsh_file" + _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" fi else rm -f "$bash_file" "$fish_file" 2>/dev/null || true diff --git a/scripts/install.sh b/scripts/install.sh index 0555d920..136e928e 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -85,13 +85,33 @@ _aenv_cc_put() { chmod "$mode" "$path" 2>/dev/null || true } -# Append the regenerating zsh rc-snippet to ~/.zshrc, idempotently. +# Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: +# strictly alternating start/end pairs with no nesting, reordering, or +# unterminated start at EOF. Returns 1 otherwise. Used to gate both install +# idempotency and removal so a corrupted/partial block is never silently +# truncated and never auto-repaired at the cost of unrelated rc content. +_aenv_cc_rc_well_formed() { + awk ' + BEGIN { in_block = 0 } + /^# >>> aenv completion >>>$/ { if (in_block) exit 1; in_block = 1; next } + /^# <<< aenv completion <<<$/ { if (!in_block) exit 1; in_block = 0; next } + END { if (in_block) exit 1 } + ' "$1" 2>/dev/null +} + +# Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed +# block already present => no-op. A partial/corrupted block => warn and leave it +# for the user (auto-repair could delete unrelated rc lines). No block => append. # $1 rc file path _aenv_cc_put_zsh_rc() { local rc="$1" local dir="${rc%/*}" - if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc"; then - return 0 # already installed + if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null; then + if _aenv_cc_rc_well_formed "$rc"; then + return 0 # idempotent: a complete managed block already exists + fi + printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + return 0 fi if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 @@ -117,55 +137,62 @@ _aenv_cc_put_zsh_rc() { fi } -# Generate the static zsh completion from the installed aenv into a -# site-functions dir. Skipped (with a warning) if `aenv` is not on PATH, e.g. -# when installing into a prefix that is not yet on PATH; re-running the -# installer after fixing PATH regenerates it. +# Generate the static zsh completion into a site-functions dir. $2 is the +# just-installed aenv binary (preferred over whatever is on PATH, which may be +# stale or absent). Generation goes to a temp file in the destination dir and +# is atomically renamed on success, so a failure never truncates an existing +# valid completion file. # $1 destination _aenv path +# $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { - local path="$1" + local path="$1" aenv_bin="${2:-aenv}" local dir="${path%/*}" if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - if ! command -v aenv >/dev/null 2>&1; then - printf 'warn: aenv completion: aenv not on PATH; skipping static zsh file %s\n' "$path" >&2 + local gen=() + if [[ -x "$aenv_bin" ]]; then + gen=("$aenv_bin" completion zsh) + elif command -v aenv >/dev/null 2>&1; then + gen=(aenv completion zsh) + else + printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 return 0 fi - if ! aenv completion zsh > "$path" 2>/dev/null; then - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 - return 0 + local tmp + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp)" + if "${gen[@]}" > "$tmp" 2>/dev/null; then + chmod 0644 "$tmp" 2>/dev/null || true + if mv -f "$tmp" "$path" 2>/dev/null; then + return 0 + fi fi - chmod 0644 "$path" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 } -# Remove the zsh rc-snippet block from ~/.zshrc. Only acts on a balanced -# marker pair; an unbalanced pair is left untouched to avoid truncating the -# user's rc file. +# Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a +# file with a malformed (partial/nested/reordered) block. The rewrite is done +# in place (cat onto the rc) so the rc's inode, mode, ownership, and — for a +# symlinked rc — the link target are preserved. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" [[ -f "$rc" ]] || return 0 - local starts ends tmp - starts=$(grep -c '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || true) - ends=$(grep -c '^# <<< aenv completion <<<$' "$rc" 2>/dev/null || true) - starts="${starts:-0}" - ends="${ends:-0}" - [[ "$starts" =~ ^[0-9]+$ ]] || starts=0 - [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 - [[ "$starts" -gt 0 ]] || return 0 - if [[ "$starts" -ne "$ends" ]]; then - printf 'warn: aenv completion: unbalanced markers in %s; leaving it untouched\n' "$rc" >&2 + grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || return 0 + if ! _aenv_cc_rc_well_formed "$rc"; then + printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi + local tmp tmp="$(mktemp)" - if awk ' - /^# >>> aenv completion >>>$/ { skip=1; next } - /^# <<< aenv completion <<<$/ { skip=0; next } - !skip { print } - ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" 2>/dev/null; then + awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null + # In-place rewrite preserves inode/mode/ownership and writes through a + # symlinked rc rather than replacing the link itself. + if cat "$tmp" > "$rc" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null || true return 0 fi rm -f "$tmp" 2>/dev/null || true @@ -195,22 +222,27 @@ aenv_completion_install() { return 0 fi + # Capture $HOME once, safely (set -u safe). It drives both mode detection + # and the user-mode destination paths, and must not be dereferenced bare. + local home="${HOME:-}" + # Auto-select user mode for a bare invocation or a prefix under $HOME. - # Guard $HOME: if it is unset/empty, the "$prefix" == "$HOME"/* pattern - # would collapse to "/*" and match any absolute path, and the bare $HOME - # reference would abort under `set -u`. Capture it once, safely. if [[ $user_mode -eq 0 ]]; then - local home="${HOME:-}" if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then user_mode=1 fi fi + if [[ $user_mode -eq 1 && -z "$home" ]]; then + printf 'warn: aenv completion: user mode requested but HOME is unset; skipping\n' >&2 + return 0 + fi + local bash_file fish_file zsh_file zsh_kind if [[ $user_mode -eq 1 ]]; then - bash_file="${HOME}/.local/share/bash-completion/completions/aenv" - fish_file="${HOME}/.config/fish/completions/aenv.fish" - zsh_file="${HOME}/.zshrc" + bash_file="${home}/.local/share/bash-completion/completions/aenv" + fish_file="${home}/.config/fish/completions/aenv.fish" + zsh_file="${home}/.zshrc" zsh_kind="rc" else bash_file="${prefix}/share/bash-completion/completions/aenv" @@ -225,7 +257,7 @@ aenv_completion_install() { if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_put_zsh_rc "$zsh_file" else - _aenv_cc_put_zsh_static "$zsh_file" + _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" fi else rm -f "$bash_file" "$fish_file" 2>/dev/null || true diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh index 4f17def8..447709b3 100755 --- a/scripts/shell-completion.sh +++ b/scripts/shell-completion.sh @@ -56,13 +56,33 @@ _aenv_cc_put() { chmod "$mode" "$path" 2>/dev/null || true } -# Append the regenerating zsh rc-snippet to ~/.zshrc, idempotently. +# Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: +# strictly alternating start/end pairs with no nesting, reordering, or +# unterminated start at EOF. Returns 1 otherwise. Used to gate both install +# idempotency and removal so a corrupted/partial block is never silently +# truncated and never auto-repaired at the cost of unrelated rc content. +_aenv_cc_rc_well_formed() { + awk ' + BEGIN { in_block = 0 } + /^# >>> aenv completion >>>$/ { if (in_block) exit 1; in_block = 1; next } + /^# <<< aenv completion <<<$/ { if (!in_block) exit 1; in_block = 0; next } + END { if (in_block) exit 1 } + ' "$1" 2>/dev/null +} + +# Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed +# block already present => no-op. A partial/corrupted block => warn and leave it +# for the user (auto-repair could delete unrelated rc lines). No block => append. # $1 rc file path _aenv_cc_put_zsh_rc() { local rc="$1" local dir="${rc%/*}" - if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc"; then - return 0 # already installed + if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null; then + if _aenv_cc_rc_well_formed "$rc"; then + return 0 # idempotent: a complete managed block already exists + fi + printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + return 0 fi if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 @@ -88,55 +108,62 @@ _aenv_cc_put_zsh_rc() { fi } -# Generate the static zsh completion from the installed aenv into a -# site-functions dir. Skipped (with a warning) if `aenv` is not on PATH, e.g. -# when installing into a prefix that is not yet on PATH; re-running the -# installer after fixing PATH regenerates it. +# Generate the static zsh completion into a site-functions dir. $2 is the +# just-installed aenv binary (preferred over whatever is on PATH, which may be +# stale or absent). Generation goes to a temp file in the destination dir and +# is atomically renamed on success, so a failure never truncates an existing +# valid completion file. # $1 destination _aenv path +# $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { - local path="$1" + local path="$1" aenv_bin="${2:-aenv}" local dir="${path%/*}" if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - if ! command -v aenv >/dev/null 2>&1; then - printf 'warn: aenv completion: aenv not on PATH; skipping static zsh file %s\n' "$path" >&2 + local gen=() + if [[ -x "$aenv_bin" ]]; then + gen=("$aenv_bin" completion zsh) + elif command -v aenv >/dev/null 2>&1; then + gen=(aenv completion zsh) + else + printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 return 0 fi - if ! aenv completion zsh > "$path" 2>/dev/null; then - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 - return 0 + local tmp + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp)" + if "${gen[@]}" > "$tmp" 2>/dev/null; then + chmod 0644 "$tmp" 2>/dev/null || true + if mv -f "$tmp" "$path" 2>/dev/null; then + return 0 + fi fi - chmod 0644 "$path" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 } -# Remove the zsh rc-snippet block from ~/.zshrc. Only acts on a balanced -# marker pair; an unbalanced pair is left untouched to avoid truncating the -# user's rc file. +# Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a +# file with a malformed (partial/nested/reordered) block. The rewrite is done +# in place (cat onto the rc) so the rc's inode, mode, ownership, and — for a +# symlinked rc — the link target are preserved. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" [[ -f "$rc" ]] || return 0 - local starts ends tmp - starts=$(grep -c '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || true) - ends=$(grep -c '^# <<< aenv completion <<<$' "$rc" 2>/dev/null || true) - starts="${starts:-0}" - ends="${ends:-0}" - [[ "$starts" =~ ^[0-9]+$ ]] || starts=0 - [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 - [[ "$starts" -gt 0 ]] || return 0 - if [[ "$starts" -ne "$ends" ]]; then - printf 'warn: aenv completion: unbalanced markers in %s; leaving it untouched\n' "$rc" >&2 + grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || return 0 + if ! _aenv_cc_rc_well_formed "$rc"; then + printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi + local tmp tmp="$(mktemp)" - if awk ' - /^# >>> aenv completion >>>$/ { skip=1; next } - /^# <<< aenv completion <<<$/ { skip=0; next } - !skip { print } - ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" 2>/dev/null; then + awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null + # In-place rewrite preserves inode/mode/ownership and writes through a + # symlinked rc rather than replacing the link itself. + if cat "$tmp" > "$rc" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null || true return 0 fi rm -f "$tmp" 2>/dev/null || true @@ -166,22 +193,27 @@ aenv_completion_install() { return 0 fi + # Capture $HOME once, safely (set -u safe). It drives both mode detection + # and the user-mode destination paths, and must not be dereferenced bare. + local home="${HOME:-}" + # Auto-select user mode for a bare invocation or a prefix under $HOME. - # Guard $HOME: if it is unset/empty, the "$prefix" == "$HOME"/* pattern - # would collapse to "/*" and match any absolute path, and the bare $HOME - # reference would abort under `set -u`. Capture it once, safely. if [[ $user_mode -eq 0 ]]; then - local home="${HOME:-}" if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then user_mode=1 fi fi + if [[ $user_mode -eq 1 && -z "$home" ]]; then + printf 'warn: aenv completion: user mode requested but HOME is unset; skipping\n' >&2 + return 0 + fi + local bash_file fish_file zsh_file zsh_kind if [[ $user_mode -eq 1 ]]; then - bash_file="${HOME}/.local/share/bash-completion/completions/aenv" - fish_file="${HOME}/.config/fish/completions/aenv.fish" - zsh_file="${HOME}/.zshrc" + bash_file="${home}/.local/share/bash-completion/completions/aenv" + fish_file="${home}/.config/fish/completions/aenv.fish" + zsh_file="${home}/.zshrc" zsh_kind="rc" else bash_file="${prefix}/share/bash-completion/completions/aenv" @@ -196,7 +228,7 @@ aenv_completion_install() { if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_put_zsh_rc "$zsh_file" else - _aenv_cc_put_zsh_static "$zsh_file" + _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" fi else rm -f "$bash_file" "$fish_file" 2>/dev/null || true diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index af34f279..7716a3bf 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -28,6 +28,16 @@ EOF chmod +x "$fake_bin/aenv" export PATH="$fake_bin:$PATH" +# A PATH containing only the utilities the helper needs and NO aenv, so the +# "aenv missing" case is hermetic regardless of what is installed on the host +# (no reliance on /usr/bin/aenv or /bin/aenv existing or not). `bash` is +# included so the test can launch the helper under this restricted PATH. +hermetic_bin="$tmp_root/hermetic-bin" +mkdir -p "$hermetic_bin" +for u in bash mkdir grep awk tail od tr mktemp cat chmod rm mv; do + ln -s "$(command -v "$u")" "$hermetic_bin/$u" +done + fail() { echo "FAIL: $*" >&2; exit 1; } assert_contains() { # file needle [[ -f "$1" ]] || fail "expected file $1 to exist" @@ -129,12 +139,12 @@ rm -f "$malformed" # still writes the bash/fish stubs (graceful degradation, non-fatal). # --------------------------------------------------------------------------- echo "==> system mode without aenv on PATH skips only the static zsh file" -# A PATH that contains neither the fake aenv nor any other aenv. -HOME="$fake_home" PATH="/usr/bin:/bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null +# Hermetic PATH: only the utilities the helper needs, no aenv anywhere. +HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'aenv completion fish | source' assert_absent "$sys_prefix/share/zsh/site-functions/_aenv" -HOME="$fake_home" PATH="/usr/bin:/bin" bash "$helper" uninstall --prefix="$sys_prefix" +HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" uninstall --prefix="$sys_prefix" assert_absent "$sys_prefix/share/bash-completion/completions/aenv" # --------------------------------------------------------------------------- @@ -149,4 +159,46 @@ assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'source <(a env -u HOME bash "$helper" uninstall --prefix="$sys_prefix" >/dev/null 2>&1 assert_absent "$sys_prefix/share/bash-completion/completions/aenv" +# --------------------------------------------------------------------------- +# Test 8: a failing `aenv completion zsh` must not truncate an existing valid +# static file (atomic temp+rename), and must not leave temp files behind. +# --------------------------------------------------------------------------- +echo "==> failing aenv completion zsh leaves the existing _aenv intact" +mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" +printf '#!/usr/bin/env bash\nexit 1\n' > "$sys_prefix/bin/aenv" +chmod +x "$sys_prefix/bin/aenv" +echo '# pre-existing valid zsh completion' > "$sys_prefix/share/zsh/site-functions/_aenv" +HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ + || fail "helper aborted when aenv completion zsh exits nonzero" +assert_contains "$sys_prefix/share/zsh/site-functions/_aenv" '# pre-existing valid zsh completion' +# Exactly one file in the site-functions dir (no leftover .XXXXXX temp). +leftovers=( "$sys_prefix/share/zsh/site-functions"/* ) +[[ "${#leftovers[@]}" -eq 1 ]] || fail "expected no temp leftovers, found: ${leftovers[*]}" +rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share/zsh/site-functions" + +# --------------------------------------------------------------------------- +# Test 9: unrelated user lines around the managed block survive install+uninstall +# (a regression that truncates the rc while removing a balanced block must fail). +# --------------------------------------------------------------------------- +echo "==> unrelated rc content survives install and uninstall" +home_surround="$tmp_root/home-surround" +mkdir -p "$home_surround" +zsrc="$home_surround/.zshrc" +printf 'alias-before=1\n' > "$zsrc" +HOME="$home_surround" bash "$helper" install --user +printf 'alias-after=2\n' >> "$zsrc" +HOME="$home_surround" bash "$helper" uninstall --user +assert_contains "$zsrc" 'alias-before=1' +assert_contains "$zsrc" 'alias-after=2' +assert_rc_clean "$zsrc" + +# --------------------------------------------------------------------------- +# Test 10: user mode requested with HOME unset warns and skips (no abort under +# set -u); closes the non-fatal contract for the user-mode destination paths. +# --------------------------------------------------------------------------- +echo "==> user mode with unset HOME warns and skips without aborting" +env -u HOME bash "$helper" install --user >/tmp/aenv_tc_out 2>&1 \ + || fail "helper aborted with --user under unset HOME" +grep -q 'HOME is unset' /tmp/aenv_tc_out || fail "expected a HOME-unset warning" + echo "==> all shell-completion checks passed" From 6145167cfbe66a835dfb7154c13f0b7126c9c383 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sat, 1 Aug 2026 22:22:35 +0200 Subject: [PATCH 12/23] Update scripts/tests/verify-shell-completion.sh Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/tests/verify-shell-completion.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index 7716a3bf..261246d5 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -197,8 +197,9 @@ assert_rc_clean "$zsrc" # set -u); closes the non-fatal contract for the user-mode destination paths. # --------------------------------------------------------------------------- echo "==> user mode with unset HOME warns and skips without aborting" -env -u HOME bash "$helper" install --user >/tmp/aenv_tc_out 2>&1 \ +home_unset_out="$tmp_root/home-unset.out" +env -u HOME bash "$helper" install --user >"$home_unset_out" 2>&1 \ || fail "helper aborted with --user under unset HOME" -grep -q 'HOME is unset' /tmp/aenv_tc_out || fail "expected a HOME-unset warning" +grep -q 'HOME is unset' "$home_unset_out" || fail "expected a HOME-unset warning" echo "==> all shell-completion checks passed" From 08622c1f4d7d5422050643006c0b4ddd95c9da62 Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sat, 1 Aug 2026 22:22:54 +0200 Subject: [PATCH 13/23] Update scripts/tests/verify-shell-completion.sh Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/tests/verify-shell-completion.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index 261246d5..f392b141 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -47,8 +47,8 @@ assert_absent() { # path [[ ! -e "$1" ]] || fail "expected $1 to be absent, but it exists" } assert_rc_clean() { # rc-file - [[ ! -f "$1" ]] || ! grep -q '^# >>> aenv completion >>>$' "$1" \ - || fail "expected no aenv marker block in $1" + [[ ! -f "$1" ]] || ! grep -Eq '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$1" \ + || fail "expected no aenv markers in $1" } marker_count() { # rc-file -> count if [[ -f "$1" ]]; then From 72d8e699e23fd39962afb7c6ab4961cb86a8723a Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sun, 2 Aug 2026 10:12:47 +0200 Subject: [PATCH 14/23] fix(cli): address second round of review on completion installers Canonical helper (scripts/shell-completion.sh, inlined verbatim into install-cli.sh and install.sh): - Treat an orphan END marker as malformed (validation now triggers when either marker is present, not only on a start marker), so an rc with a lone END no longer gets a fresh block appended onto corrupt state. - Validate the awk rewrite before touching the live rc: chain `awk > tmp && cat tmp > rc` so a failed/partial awk never truncates ~/.zshrc (previously `cat` ran on an unvalidated tmp). - Guard every mktemp fallback and the awk/cat chain so a failure warns and returns 0 instead of aborting under set -e (closes the non-fatal contract for both _aenv_cc_put_zsh_static and _aenv_cc_rm_zsh_rc). - Guard the zsh rc-snippet with `command -v aenv` so a missing/uninstalled aenv no longer emits errors on every shell start. Drift checker: also require each installer to invoke the helper exactly once, so a deleted/broken call site cannot hide while the inlined blocks remain "in sync". Tests: - Malformed-marker safety now covers orphan-start, orphan-end, reversed, and nested layouts for both install and uninstall, asserting the rc is byte-for-byte unchanged. - Test 6 also asserts the fish completion is removed on uninstall. - Test 8 writes an invocation sentinel so it proves the prefix-local aenv was actually invoked (not just that _aenv was preserved). --- scripts/check-completion-sync.sh | 8 +++++ scripts/install-cli.sh | 40 ++++++++++++++---------- scripts/install.sh | 40 ++++++++++++++---------- scripts/shell-completion.sh | 40 ++++++++++++++---------- scripts/tests/verify-shell-completion.sh | 39 ++++++++++++++++------- 5 files changed, 107 insertions(+), 60 deletions(-) diff --git a/scripts/check-completion-sync.sh b/scripts/check-completion-sync.sh index 87647b47..4801c708 100755 --- a/scripts/check-completion-sync.sh +++ b/scripts/check-completion-sync.sh @@ -64,6 +64,14 @@ for f in "$cli" "$full"; do diff -u "$tmp_dir/canonical" "$tmp_dir/cand" >&2 || true rc=1 fi + # The block being present is not enough: each installer must also actually + # invoke the helper exactly once (a deleted/broken call site would otherwise + # leave the install silently broken while the blocks stay "in sync"). + calls=$(grep -cE '^aenv_completion_install (install|uninstall) ' "$f" || true) + if [[ "$calls" -ne 1 ]]; then + echo "error: expected exactly one aenv_completion_install invocation in $f (found $calls)" >&2 + rc=1 + fi done if [[ $rc -eq 0 ]]; then diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 541a6743..b4f255b6 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -90,9 +90,9 @@ _aenv_cc_put() { } # Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: -# strictly alternating start/end pairs with no nesting, reordering, or -# unterminated start at EOF. Returns 1 otherwise. Used to gate both install -# idempotency and removal so a corrupted/partial block is never silently +# strictly alternating start/end pairs with no nesting, reordering, orphan +# markers, or unterminated start at EOF. Returns 1 otherwise. Used to gate both +# install idempotency and removal so a corrupted/partial block is never silently # truncated and never auto-repaired at the cost of unrelated rc content. _aenv_cc_rc_well_formed() { awk ' @@ -104,13 +104,15 @@ _aenv_cc_rc_well_formed() { } # Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed -# block already present => no-op. A partial/corrupted block => warn and leave it -# for the user (auto-repair could delete unrelated rc lines). No block => append. +# block already present => no-op. Any marker present but malformed => warn and +# leave it for the user (auto-repair could delete unrelated rc lines). No +# markers => append. The appended block is guarded by `command -v aenv` so a +# missing/broken aenv never emits errors on every shell start. # $1 rc file path _aenv_cc_put_zsh_rc() { local rc="$1" local dir="${rc%/*}" - if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null; then + if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then if _aenv_cc_rc_well_formed "$rc"; then return 0 # idempotent: a complete managed block already exists fi @@ -132,9 +134,11 @@ _aenv_cc_put_zsh_rc() { if ! { printf '%s' "$leader" printf '# >>> aenv completion >>>\n' + printf 'if command -v aenv >/dev/null 2>&1; then\n' printf 'autoload -Uz compinit && compinit\n' # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash printf 'eval "$(aenv completion zsh)"\n' + printf 'fi\n' printf '# <<< aenv completion <<<\n' } >> "$rc" 2>/dev/null; then printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 @@ -165,7 +169,10 @@ _aenv_cc_put_zsh_static() { return 0 fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp)" + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp 2>/dev/null)" || { + printf 'warn: aenv completion: mktemp failed; skipping static zsh file %s\n' "$path" >&2 + return 0 + } if "${gen[@]}" > "$tmp" 2>/dev/null; then chmod 0644 "$tmp" 2>/dev/null || true if mv -f "$tmp" "$path" 2>/dev/null; then @@ -178,24 +185,25 @@ _aenv_cc_put_zsh_static() { } # Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered) block. The rewrite is done -# in place (cat onto the rc) so the rc's inode, mode, ownership, and — for a -# symlinked rc — the link target are preserved. +# file with a malformed (partial/nested/reordered/orphan) block. The awk output +# is validated and the rc rewritten in place (cat onto the rc) so the rc's +# inode, mode, ownership, and — for a symlinked rc — the link target are +# preserved; a failed/partial awk never reaches the live rc. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" [[ -f "$rc" ]] || return 0 - grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || return 0 + grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 if ! _aenv_cc_rc_well_formed "$rc"; then printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi local tmp - tmp="$(mktemp)" - awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null - # In-place rewrite preserves inode/mode/ownership and writes through a - # symlinked rc rather than replacing the link itself. - if cat "$tmp" > "$rc" 2>/dev/null; then + tmp="$(mktemp 2>/dev/null)" || { + printf 'warn: aenv completion: mktemp failed; leaving %s untouched\n' "$rc" >&2 + return 0 + } + if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null && cat "$tmp" > "$rc" 2>/dev/null; then rm -f "$tmp" 2>/dev/null || true return 0 fi diff --git a/scripts/install.sh b/scripts/install.sh index 136e928e..5e366405 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -86,9 +86,9 @@ _aenv_cc_put() { } # Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: -# strictly alternating start/end pairs with no nesting, reordering, or -# unterminated start at EOF. Returns 1 otherwise. Used to gate both install -# idempotency and removal so a corrupted/partial block is never silently +# strictly alternating start/end pairs with no nesting, reordering, orphan +# markers, or unterminated start at EOF. Returns 1 otherwise. Used to gate both +# install idempotency and removal so a corrupted/partial block is never silently # truncated and never auto-repaired at the cost of unrelated rc content. _aenv_cc_rc_well_formed() { awk ' @@ -100,13 +100,15 @@ _aenv_cc_rc_well_formed() { } # Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed -# block already present => no-op. A partial/corrupted block => warn and leave it -# for the user (auto-repair could delete unrelated rc lines). No block => append. +# block already present => no-op. Any marker present but malformed => warn and +# leave it for the user (auto-repair could delete unrelated rc lines). No +# markers => append. The appended block is guarded by `command -v aenv` so a +# missing/broken aenv never emits errors on every shell start. # $1 rc file path _aenv_cc_put_zsh_rc() { local rc="$1" local dir="${rc%/*}" - if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null; then + if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then if _aenv_cc_rc_well_formed "$rc"; then return 0 # idempotent: a complete managed block already exists fi @@ -128,9 +130,11 @@ _aenv_cc_put_zsh_rc() { if ! { printf '%s' "$leader" printf '# >>> aenv completion >>>\n' + printf 'if command -v aenv >/dev/null 2>&1; then\n' printf 'autoload -Uz compinit && compinit\n' # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash printf 'eval "$(aenv completion zsh)"\n' + printf 'fi\n' printf '# <<< aenv completion <<<\n' } >> "$rc" 2>/dev/null; then printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 @@ -161,7 +165,10 @@ _aenv_cc_put_zsh_static() { return 0 fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp)" + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp 2>/dev/null)" || { + printf 'warn: aenv completion: mktemp failed; skipping static zsh file %s\n' "$path" >&2 + return 0 + } if "${gen[@]}" > "$tmp" 2>/dev/null; then chmod 0644 "$tmp" 2>/dev/null || true if mv -f "$tmp" "$path" 2>/dev/null; then @@ -174,24 +181,25 @@ _aenv_cc_put_zsh_static() { } # Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered) block. The rewrite is done -# in place (cat onto the rc) so the rc's inode, mode, ownership, and — for a -# symlinked rc — the link target are preserved. +# file with a malformed (partial/nested/reordered/orphan) block. The awk output +# is validated and the rc rewritten in place (cat onto the rc) so the rc's +# inode, mode, ownership, and — for a symlinked rc — the link target are +# preserved; a failed/partial awk never reaches the live rc. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" [[ -f "$rc" ]] || return 0 - grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || return 0 + grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 if ! _aenv_cc_rc_well_formed "$rc"; then printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi local tmp - tmp="$(mktemp)" - awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null - # In-place rewrite preserves inode/mode/ownership and writes through a - # symlinked rc rather than replacing the link itself. - if cat "$tmp" > "$rc" 2>/dev/null; then + tmp="$(mktemp 2>/dev/null)" || { + printf 'warn: aenv completion: mktemp failed; leaving %s untouched\n' "$rc" >&2 + return 0 + } + if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null && cat "$tmp" > "$rc" 2>/dev/null; then rm -f "$tmp" 2>/dev/null || true return 0 fi diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh index 447709b3..ab61a9c2 100755 --- a/scripts/shell-completion.sh +++ b/scripts/shell-completion.sh @@ -57,9 +57,9 @@ _aenv_cc_put() { } # Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: -# strictly alternating start/end pairs with no nesting, reordering, or -# unterminated start at EOF. Returns 1 otherwise. Used to gate both install -# idempotency and removal so a corrupted/partial block is never silently +# strictly alternating start/end pairs with no nesting, reordering, orphan +# markers, or unterminated start at EOF. Returns 1 otherwise. Used to gate both +# install idempotency and removal so a corrupted/partial block is never silently # truncated and never auto-repaired at the cost of unrelated rc content. _aenv_cc_rc_well_formed() { awk ' @@ -71,13 +71,15 @@ _aenv_cc_rc_well_formed() { } # Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed -# block already present => no-op. A partial/corrupted block => warn and leave it -# for the user (auto-repair could delete unrelated rc lines). No block => append. +# block already present => no-op. Any marker present but malformed => warn and +# leave it for the user (auto-repair could delete unrelated rc lines). No +# markers => append. The appended block is guarded by `command -v aenv` so a +# missing/broken aenv never emits errors on every shell start. # $1 rc file path _aenv_cc_put_zsh_rc() { local rc="$1" local dir="${rc%/*}" - if [[ -f "$rc" ]] && grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null; then + if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then if _aenv_cc_rc_well_formed "$rc"; then return 0 # idempotent: a complete managed block already exists fi @@ -99,9 +101,11 @@ _aenv_cc_put_zsh_rc() { if ! { printf '%s' "$leader" printf '# >>> aenv completion >>>\n' + printf 'if command -v aenv >/dev/null 2>&1; then\n' printf 'autoload -Uz compinit && compinit\n' # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash printf 'eval "$(aenv completion zsh)"\n' + printf 'fi\n' printf '# <<< aenv completion <<<\n' } >> "$rc" 2>/dev/null; then printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 @@ -132,7 +136,10 @@ _aenv_cc_put_zsh_static() { return 0 fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp)" + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp 2>/dev/null)" || { + printf 'warn: aenv completion: mktemp failed; skipping static zsh file %s\n' "$path" >&2 + return 0 + } if "${gen[@]}" > "$tmp" 2>/dev/null; then chmod 0644 "$tmp" 2>/dev/null || true if mv -f "$tmp" "$path" 2>/dev/null; then @@ -145,24 +152,25 @@ _aenv_cc_put_zsh_static() { } # Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered) block. The rewrite is done -# in place (cat onto the rc) so the rc's inode, mode, ownership, and — for a -# symlinked rc — the link target are preserved. +# file with a malformed (partial/nested/reordered/orphan) block. The awk output +# is validated and the rc rewritten in place (cat onto the rc) so the rc's +# inode, mode, ownership, and — for a symlinked rc — the link target are +# preserved; a failed/partial awk never reaches the live rc. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" [[ -f "$rc" ]] || return 0 - grep -q '^# >>> aenv completion >>>$' "$rc" 2>/dev/null || return 0 + grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 if ! _aenv_cc_rc_well_formed "$rc"; then printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi local tmp - tmp="$(mktemp)" - awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null - # In-place rewrite preserves inode/mode/ownership and writes through a - # symlinked rc rather than replacing the link itself. - if cat "$tmp" > "$rc" 2>/dev/null; then + tmp="$(mktemp 2>/dev/null)" || { + printf 'warn: aenv completion: mktemp failed; leaving %s untouched\n' "$rc" >&2 + return 0 + } + if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null && cat "$tmp" > "$rc" 2>/dev/null; then rm -f "$tmp" 2>/dev/null || true return 0 fi diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index f392b141..da33fd7e 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -122,17 +122,29 @@ HOME="$fake_home" bash "$helper" uninstall --user HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" # --------------------------------------------------------------------------- -# Test 5: an unbalanced marker block is left untouched (never truncate a rc) -# --------------------------------------------------------------------------- -echo "==> unbalanced markers are left untouched on uninstall" -malformed="$fake_home/.zshrc" -printf 'user-line-before\n# >>> aenv completion >>>\nautoload -Uz compinit\nuser-line-after\n' > "$malformed" -HOME="$fake_home" bash "$helper" uninstall --user 2>/dev/null -# Nothing is removed: both user lines and the orphan start marker remain. -assert_contains "$malformed" 'user-line-before' -assert_contains "$malformed" 'user-line-after' -assert_contains "$malformed" '# >>> aenv completion >>>' -rm -f "$malformed" +# Test 5: every malformed marker layout is left byte-for-byte untouched by both +# install and uninstall (orphan start/end, reversed, nested). Install must not +# append onto a malformed state; uninstall must not truncate it. +# --------------------------------------------------------------------------- +echo "==> malformed marker layouts are untouched by install and uninstall" +home_mal="$tmp_root/home-mal"; mkdir -p "$home_mal" +layouts=( + 'orphan-start|user-before\n# >>> aenv completion >>>\nuser-after\n' + 'orphan-end|user-before\n# <<< aenv completion <<<\nuser-after\n' + 'reversed|# <<< aenv completion <<<\nuser-mid\n# >>> aenv completion >>>\n' + 'nested|# >>> aenv completion >>>\n# >>> aenv completion >>>\nx\n# <<< aenv completion <<<\n# <<< aenv completion <<<\n' +) +for entry in "${layouts[@]}"; do + name="${entry%%|*}"; body="${entry#*|}" + rc="$home_mal/.zshrc" + printf '%b' "$body" > "$rc" + cp "$rc" "$rc.orig" + HOME="$home_mal" bash "$helper" install --user 2>/dev/null + cmp -s "$rc" "$rc.orig" || fail "install mutated malformed ($name) rc" + HOME="$home_mal" bash "$helper" uninstall --user 2>/dev/null + cmp -s "$rc" "$rc.orig" || fail "uninstall mutated malformed ($name) rc" + rm -f "$rc" "$rc.orig" +done # --------------------------------------------------------------------------- # Test 6: system mode skips the static zsh file when aenv is not on PATH but @@ -146,6 +158,7 @@ assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'aenv co assert_absent "$sys_prefix/share/zsh/site-functions/_aenv" HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" uninstall --prefix="$sys_prefix" assert_absent "$sys_prefix/share/bash-completion/completions/aenv" +assert_absent "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" # --------------------------------------------------------------------------- # Test 7: a missing $HOME must not abort the helper (set -u) and must not make @@ -165,11 +178,13 @@ assert_absent "$sys_prefix/share/bash-completion/completions/aenv" # --------------------------------------------------------------------------- echo "==> failing aenv completion zsh leaves the existing _aenv intact" mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" -printf '#!/usr/bin/env bash\nexit 1\n' > "$sys_prefix/bin/aenv" +invoked="$tmp_root/failing-aenv-invoked" +printf '#!/usr/bin/env bash\nprintf x > "%s"\nexit 1\n' "$invoked" > "$sys_prefix/bin/aenv" chmod +x "$sys_prefix/bin/aenv" echo '# pre-existing valid zsh completion' > "$sys_prefix/share/zsh/site-functions/_aenv" HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ || fail "helper aborted when aenv completion zsh exits nonzero" +[[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked (regression: fell back to PATH/no-aenv branch)" assert_contains "$sys_prefix/share/zsh/site-functions/_aenv" '# pre-existing valid zsh completion' # Exactly one file in the site-functions dir (no leftover .XXXXXX temp). leftovers=( "$sys_prefix/share/zsh/site-functions"/* ) From 8f196ab1536643d6c1ebfb4692ebe9dd6f64c780 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sun, 2 Aug 2026 10:42:50 +0200 Subject: [PATCH 15/23] refactor(cli): unify completion writes through one atomic-commit primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review found that the previous rounds fixed the atomicity / symlink / metadata properties at one write site at a time while sibling sites kept the old behavior — the review bot then re-flagged the same class of bug in the adjacent function across consecutive rounds. Funnel ALL filesystem writes through a single `_aenv_cc_commit ` primitive so the pattern physically cannot drift between functions. All four write sites now: stage to a temp created IN the destination directory (same filesystem -> atomic rename), resolve a symlinked destination so the link is preserved (not replaced), and copy the destination's current mode (0644 for a new file): - `_aenv_cc_put` (bash/fish stubs): was `printf > path` (truncated, followed symlinks); now atomic + symlink-safe. - `_aenv_cc_put_zsh_rc` (rc append): was `>>` (6 separate writes, not atomic, interruption left a malformed block); now stages the full new rc and renames. - `_aenv_cc_put_zsh_static`: already atomic; routed through the primitive for consistency. - `_aenv_cc_rm_zsh_rc` (rc removal): was temp in /tmp + cross-FS mv (not atomic); now uses a same-directory temp via the primitive. Net: every write shares the same atomic / symlink-preserving / mode-preserving behavior, closing the consistency gap that produced the repeated findings. --- scripts/install-cli.sh | 156 ++++++++++++++++++++++++++---------- scripts/install.sh | 147 ++++++++++++++++++++++++--------- scripts/shell-completion.sh | 147 ++++++++++++++++++++++++--------- 3 files changed, 326 insertions(+), 124 deletions(-) diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index b4f255b6..394fd03d 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -70,8 +70,48 @@ run_privileged() { # BEGIN aenv_completion_install # (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, # scripts/install.sh — verified by scripts/check-completion-sync.sh) +# +# All filesystem writes flow through ONE atomic-commit primitive +# (`_aenv_cc_commit`): the new content is staged to a temp file created IN the +# destination directory (same filesystem => an atomic rename), the destination's +# symlink is resolved (so the link is preserved, not replaced) and its mode is +# copied. This guarantees every write site shares the same atomicity / symlink / +# metadata properties, so the pattern cannot drift between functions. + +# Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the +# mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, +# BSD/macOS stat uses -f. +_aenv_cc_mode_octal() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '0644' +} + +# Resolve $1 to the real file path it refers to when it is a symlink, so writes +# land on the target and preserve the link rather than replacing it. Falls back +# to $1 when readlink is unavailable or $1 is not a symlink. +_aenv_cc_resolve() { + if [[ -L "$1" ]]; then + readlink -f "$1" 2>/dev/null || printf '%s' "$1" + else + printf '%s' "$1" + fi +} + +# Atomically publish a staged temp file as . +# $1 temp file path — MUST live in the same directory as (caller's job) +# so the final rename is atomic and not a cross-filesystem copy+delete. +# $2 destination path (possibly a symlink; its target is replaced, the link +# itself is preserved). The destination's current mode is copied onto the +# temp first (0644 default for a new file). +# Returns nonzero on failure; the caller is responsible for cleaning up the temp. +_aenv_cc_commit() { + local tmp="$1" dest="$2" + local target + target="$(_aenv_cc_resolve "$dest")" + chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$target" 2>/dev/null +} -# Write a single completion loader file. Non-fatal on I/O errors. +# Write a single completion loader file atomically. Non-fatal on I/O errors. # $1 destination path # $2 file mode (e.g. 0644) # $3 loader content (single line; the loaders are one-liners by design) @@ -82,11 +122,17 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - if ! printf '%s\n' "$content" > "$path" 2>/dev/null; then - printf 'warn: aenv completion: could not write %s\n' "$path" >&2 + local tmp + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 + return 0 + } + if printf '%s\n' "$content" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$path"; then + chmod "$mode" "$path" 2>/dev/null || true return 0 fi - chmod "$mode" "$path" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not write %s\n' "$path" >&2 } # Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: @@ -103,10 +149,13 @@ _aenv_cc_rc_well_formed() { ' "$1" 2>/dev/null } -# Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed -# block already present => no-op. Any marker present but malformed => warn and -# leave it for the user (auto-repair could delete unrelated rc lines). No -# markers => append. The appended block is guarded by `command -v aenv` so a +# Append the regenerating zsh rc-snippet, idempotently and atomically. A +# complete, well-formed block already present => no-op. Any marker present but +# malformed => warn and leave it for the user (auto-repair could delete +# unrelated rc lines). No markers => append. The full new rc (existing content +# + managed block) is staged to a same-directory temp and committed by an atomic +# rename, so an interruption or I/O failure never leaves a partial/malformed +# block in the live rc. The appended block is guarded by `command -v aenv` so a # missing/broken aenv never emits errors on every shell start. # $1 rc file path _aenv_cc_put_zsh_rc() { @@ -123,16 +172,21 @@ _aenv_cc_put_zsh_rc() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - # Start the block on its own line only when the rc file is non-empty and - # does not already end with a newline; this avoids leaving a stray blank - # line behind after uninstall. - local leader="" last_byte - if [[ -s "$rc" ]]; then - last_byte=$(tail -c 1 "$rc" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - [[ "$last_byte" == "0a" ]] || leader=$'\n' + local target tmp last_byte + target="$(_aenv_cc_resolve "$rc")" + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 + return 0 + } + # Stage existing content first (guarded), then add a separating newline if + # the existing content did not end in one, then the managed block. Each step + # returns nonzero on I/O failure so we never commit a partial result. + if [[ -s "$target" ]]; then + cat "$target" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } + last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } fi if ! { - printf '%s' "$leader" printf '# >>> aenv completion >>>\n' printf 'if command -v aenv >/dev/null 2>&1; then\n' printf 'autoload -Uz compinit && compinit\n' @@ -140,16 +194,21 @@ _aenv_cc_put_zsh_rc() { printf 'eval "$(aenv completion zsh)"\n' printf 'fi\n' printf '# <<< aenv completion <<<\n' - } >> "$rc" 2>/dev/null; then - printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 + } >> "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 + return 0 fi + _aenv_cc_commit "$tmp" "$rc" || { + rm -f "$tmp" 2>/dev/null + printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 + } } # Generate the static zsh completion into a site-functions dir. $2 is the # just-installed aenv binary (preferred over whatever is on PATH, which may be -# stale or absent). Generation goes to a temp file in the destination dir and -# is atomically renamed on success, so a failure never truncates an existing -# valid completion file. +# stale or absent). Generation goes through `_aenv_cc_commit`, so a failure or +# empty output never replaces an existing valid completion file. # $1 destination _aenv path # $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { @@ -169,26 +228,27 @@ _aenv_cc_put_zsh_static() { return 0 fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp 2>/dev/null)" || { - printf 'warn: aenv completion: mktemp failed; skipping static zsh file %s\n' "$path" >&2 + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 return 0 } - if "${gen[@]}" > "$tmp" 2>/dev/null; then - chmod 0644 "$tmp" 2>/dev/null || true - if mv -f "$tmp" "$path" 2>/dev/null; then - return 0 - fi + # Require non-empty output: a broken aenv that exits 0 with no bytes must not + # erase a working completion via the atomic rename. + if "${gen[@]}" > "$tmp" 2>/dev/null && [[ -s "$tmp" ]] && _aenv_cc_commit "$tmp" "$path"; then + chmod 0644 "$path" 2>/dev/null || true + return 0 fi rm -f "$tmp" 2>/dev/null || true # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 + printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 } # Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered/orphan) block. The awk output -# is validated and the rc rewritten in place (cat onto the rc) so the rc's -# inode, mode, ownership, and — for a symlinked rc — the link target are -# preserved; a failed/partial awk never reaches the live rc. +# file with a malformed (partial/nested/reordered/orphan) block. The rewrite is +# staged to a same-directory temp and committed by an atomic rename via +# `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a symlinked rc) +# the link itself are preserved and a failed/partial awk never reaches the live +# file. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" @@ -198,13 +258,17 @@ _aenv_cc_rm_zsh_rc() { printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi - local tmp - tmp="$(mktemp 2>/dev/null)" || { - printf 'warn: aenv completion: mktemp failed; leaving %s untouched\n' "$rc" >&2 + local target tmp + target="$(_aenv_cc_resolve "$rc")" + # Temp in the rc's own directory so the rename is atomic (same filesystem). + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 return 0 } - if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null && cat "$tmp" > "$rc" 2>/dev/null; then - rm -f "$tmp" 2>/dev/null || true + # awk's exit status is the signal: on success its output (possibly empty if + # the rc held only the managed block) is the correct new content; on failure + # (read/parse error) it is left partial and we never commit it. + if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then return 0 fi rm -f "$tmp" 2>/dev/null || true @@ -264,8 +328,11 @@ aenv_completion_install() { fi if [[ "$action" == "install" ]]; then - _aenv_cc_put "$bash_file" 0644 'source <(aenv completion bash)' - _aenv_cc_put "$fish_file" 0644 'aenv completion fish | source' + # bash/fish loaders guard on aenv presence so a missing/uninstalled aenv + # is silent rather than erroring on every shell start (matches the zsh + # rc-snippet's `command -v aenv` guard). + _aenv_cc_put "$bash_file" 0644 'command -v aenv >/dev/null 2>&1 && source <(aenv completion bash)' + _aenv_cc_put "$fish_file" 0644 'type -q aenv; and aenv completion fish | source' if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_put_zsh_rc "$zsh_file" else @@ -383,10 +450,11 @@ fi echo "Installed: ${DEST}" # Install regenerating shell-completion loaders (best-effort; never aborts the -# binary install). INSTALL_DIR/ maps to a prefix of INSTALL_DIR/.., which -# selects user mode (~/.zshrc + per-user completion dirs) when the binary is -# installed under $HOME and system mode (/share) otherwise. -aenv_completion_install install --prefix="${INSTALL_DIR%/*}" +# binary install). Derive the prefix from INSTALL_DIR with dirname so edge cases +# like INSTALL_DIR=/bin map to prefix=/ rather than an empty string (which would +# be misread as user mode). A prefix under $HOME selects user mode; otherwise +# system mode (/share). +aenv_completion_install install --prefix="$(dirname -- "$INSTALL_DIR")" if ! command -v aenv &>/dev/null; then echo "" diff --git a/scripts/install.sh b/scripts/install.sh index 5e366405..cbe068c6 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -66,8 +66,48 @@ curl_get() { # BEGIN aenv_completion_install # (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, # scripts/install.sh — verified by scripts/check-completion-sync.sh) +# +# All filesystem writes flow through ONE atomic-commit primitive +# (`_aenv_cc_commit`): the new content is staged to a temp file created IN the +# destination directory (same filesystem => an atomic rename), the destination's +# symlink is resolved (so the link is preserved, not replaced) and its mode is +# copied. This guarantees every write site shares the same atomicity / symlink / +# metadata properties, so the pattern cannot drift between functions. + +# Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the +# mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, +# BSD/macOS stat uses -f. +_aenv_cc_mode_octal() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '0644' +} + +# Resolve $1 to the real file path it refers to when it is a symlink, so writes +# land on the target and preserve the link rather than replacing it. Falls back +# to $1 when readlink is unavailable or $1 is not a symlink. +_aenv_cc_resolve() { + if [[ -L "$1" ]]; then + readlink -f "$1" 2>/dev/null || printf '%s' "$1" + else + printf '%s' "$1" + fi +} + +# Atomically publish a staged temp file as . +# $1 temp file path — MUST live in the same directory as (caller's job) +# so the final rename is atomic and not a cross-filesystem copy+delete. +# $2 destination path (possibly a symlink; its target is replaced, the link +# itself is preserved). The destination's current mode is copied onto the +# temp first (0644 default for a new file). +# Returns nonzero on failure; the caller is responsible for cleaning up the temp. +_aenv_cc_commit() { + local tmp="$1" dest="$2" + local target + target="$(_aenv_cc_resolve "$dest")" + chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$target" 2>/dev/null +} -# Write a single completion loader file. Non-fatal on I/O errors. +# Write a single completion loader file atomically. Non-fatal on I/O errors. # $1 destination path # $2 file mode (e.g. 0644) # $3 loader content (single line; the loaders are one-liners by design) @@ -78,11 +118,17 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - if ! printf '%s\n' "$content" > "$path" 2>/dev/null; then - printf 'warn: aenv completion: could not write %s\n' "$path" >&2 + local tmp + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 + return 0 + } + if printf '%s\n' "$content" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$path"; then + chmod "$mode" "$path" 2>/dev/null || true return 0 fi - chmod "$mode" "$path" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not write %s\n' "$path" >&2 } # Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: @@ -99,10 +145,13 @@ _aenv_cc_rc_well_formed() { ' "$1" 2>/dev/null } -# Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed -# block already present => no-op. Any marker present but malformed => warn and -# leave it for the user (auto-repair could delete unrelated rc lines). No -# markers => append. The appended block is guarded by `command -v aenv` so a +# Append the regenerating zsh rc-snippet, idempotently and atomically. A +# complete, well-formed block already present => no-op. Any marker present but +# malformed => warn and leave it for the user (auto-repair could delete +# unrelated rc lines). No markers => append. The full new rc (existing content +# + managed block) is staged to a same-directory temp and committed by an atomic +# rename, so an interruption or I/O failure never leaves a partial/malformed +# block in the live rc. The appended block is guarded by `command -v aenv` so a # missing/broken aenv never emits errors on every shell start. # $1 rc file path _aenv_cc_put_zsh_rc() { @@ -119,16 +168,21 @@ _aenv_cc_put_zsh_rc() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - # Start the block on its own line only when the rc file is non-empty and - # does not already end with a newline; this avoids leaving a stray blank - # line behind after uninstall. - local leader="" last_byte - if [[ -s "$rc" ]]; then - last_byte=$(tail -c 1 "$rc" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - [[ "$last_byte" == "0a" ]] || leader=$'\n' + local target tmp last_byte + target="$(_aenv_cc_resolve "$rc")" + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 + return 0 + } + # Stage existing content first (guarded), then add a separating newline if + # the existing content did not end in one, then the managed block. Each step + # returns nonzero on I/O failure so we never commit a partial result. + if [[ -s "$target" ]]; then + cat "$target" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } + last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } fi if ! { - printf '%s' "$leader" printf '# >>> aenv completion >>>\n' printf 'if command -v aenv >/dev/null 2>&1; then\n' printf 'autoload -Uz compinit && compinit\n' @@ -136,16 +190,21 @@ _aenv_cc_put_zsh_rc() { printf 'eval "$(aenv completion zsh)"\n' printf 'fi\n' printf '# <<< aenv completion <<<\n' - } >> "$rc" 2>/dev/null; then - printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 + } >> "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 + return 0 fi + _aenv_cc_commit "$tmp" "$rc" || { + rm -f "$tmp" 2>/dev/null + printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 + } } # Generate the static zsh completion into a site-functions dir. $2 is the # just-installed aenv binary (preferred over whatever is on PATH, which may be -# stale or absent). Generation goes to a temp file in the destination dir and -# is atomically renamed on success, so a failure never truncates an existing -# valid completion file. +# stale or absent). Generation goes through `_aenv_cc_commit`, so a failure or +# empty output never replaces an existing valid completion file. # $1 destination _aenv path # $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { @@ -165,26 +224,27 @@ _aenv_cc_put_zsh_static() { return 0 fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp 2>/dev/null)" || { - printf 'warn: aenv completion: mktemp failed; skipping static zsh file %s\n' "$path" >&2 + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 return 0 } - if "${gen[@]}" > "$tmp" 2>/dev/null; then - chmod 0644 "$tmp" 2>/dev/null || true - if mv -f "$tmp" "$path" 2>/dev/null; then - return 0 - fi + # Require non-empty output: a broken aenv that exits 0 with no bytes must not + # erase a working completion via the atomic rename. + if "${gen[@]}" > "$tmp" 2>/dev/null && [[ -s "$tmp" ]] && _aenv_cc_commit "$tmp" "$path"; then + chmod 0644 "$path" 2>/dev/null || true + return 0 fi rm -f "$tmp" 2>/dev/null || true # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 + printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 } # Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered/orphan) block. The awk output -# is validated and the rc rewritten in place (cat onto the rc) so the rc's -# inode, mode, ownership, and — for a symlinked rc — the link target are -# preserved; a failed/partial awk never reaches the live rc. +# file with a malformed (partial/nested/reordered/orphan) block. The rewrite is +# staged to a same-directory temp and committed by an atomic rename via +# `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a symlinked rc) +# the link itself are preserved and a failed/partial awk never reaches the live +# file. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" @@ -194,13 +254,17 @@ _aenv_cc_rm_zsh_rc() { printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi - local tmp - tmp="$(mktemp 2>/dev/null)" || { - printf 'warn: aenv completion: mktemp failed; leaving %s untouched\n' "$rc" >&2 + local target tmp + target="$(_aenv_cc_resolve "$rc")" + # Temp in the rc's own directory so the rename is atomic (same filesystem). + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 return 0 } - if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null && cat "$tmp" > "$rc" 2>/dev/null; then - rm -f "$tmp" 2>/dev/null || true + # awk's exit status is the signal: on success its output (possibly empty if + # the rc held only the managed block) is the correct new content; on failure + # (read/parse error) it is left partial and we never commit it. + if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then return 0 fi rm -f "$tmp" 2>/dev/null || true @@ -260,8 +324,11 @@ aenv_completion_install() { fi if [[ "$action" == "install" ]]; then - _aenv_cc_put "$bash_file" 0644 'source <(aenv completion bash)' - _aenv_cc_put "$fish_file" 0644 'aenv completion fish | source' + # bash/fish loaders guard on aenv presence so a missing/uninstalled aenv + # is silent rather than erroring on every shell start (matches the zsh + # rc-snippet's `command -v aenv` guard). + _aenv_cc_put "$bash_file" 0644 'command -v aenv >/dev/null 2>&1 && source <(aenv completion bash)' + _aenv_cc_put "$fish_file" 0644 'type -q aenv; and aenv completion fish | source' if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_put_zsh_rc "$zsh_file" else diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh index ab61a9c2..eee2e018 100755 --- a/scripts/shell-completion.sh +++ b/scripts/shell-completion.sh @@ -37,8 +37,48 @@ set -euo pipefail # BEGIN aenv_completion_install # (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, # scripts/install.sh — verified by scripts/check-completion-sync.sh) +# +# All filesystem writes flow through ONE atomic-commit primitive +# (`_aenv_cc_commit`): the new content is staged to a temp file created IN the +# destination directory (same filesystem => an atomic rename), the destination's +# symlink is resolved (so the link is preserved, not replaced) and its mode is +# copied. This guarantees every write site shares the same atomicity / symlink / +# metadata properties, so the pattern cannot drift between functions. + +# Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the +# mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, +# BSD/macOS stat uses -f. +_aenv_cc_mode_octal() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '0644' +} + +# Resolve $1 to the real file path it refers to when it is a symlink, so writes +# land on the target and preserve the link rather than replacing it. Falls back +# to $1 when readlink is unavailable or $1 is not a symlink. +_aenv_cc_resolve() { + if [[ -L "$1" ]]; then + readlink -f "$1" 2>/dev/null || printf '%s' "$1" + else + printf '%s' "$1" + fi +} -# Write a single completion loader file. Non-fatal on I/O errors. +# Atomically publish a staged temp file as . +# $1 temp file path — MUST live in the same directory as (caller's job) +# so the final rename is atomic and not a cross-filesystem copy+delete. +# $2 destination path (possibly a symlink; its target is replaced, the link +# itself is preserved). The destination's current mode is copied onto the +# temp first (0644 default for a new file). +# Returns nonzero on failure; the caller is responsible for cleaning up the temp. +_aenv_cc_commit() { + local tmp="$1" dest="$2" + local target + target="$(_aenv_cc_resolve "$dest")" + chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$target" 2>/dev/null +} + +# Write a single completion loader file atomically. Non-fatal on I/O errors. # $1 destination path # $2 file mode (e.g. 0644) # $3 loader content (single line; the loaders are one-liners by design) @@ -49,11 +89,17 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - if ! printf '%s\n' "$content" > "$path" 2>/dev/null; then - printf 'warn: aenv completion: could not write %s\n' "$path" >&2 + local tmp + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 + return 0 + } + if printf '%s\n' "$content" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$path"; then + chmod "$mode" "$path" 2>/dev/null || true return 0 fi - chmod "$mode" "$path" 2>/dev/null || true + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not write %s\n' "$path" >&2 } # Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: @@ -70,10 +116,13 @@ _aenv_cc_rc_well_formed() { ' "$1" 2>/dev/null } -# Append the regenerating zsh rc-snippet, idempotently. A complete, well-formed -# block already present => no-op. Any marker present but malformed => warn and -# leave it for the user (auto-repair could delete unrelated rc lines). No -# markers => append. The appended block is guarded by `command -v aenv` so a +# Append the regenerating zsh rc-snippet, idempotently and atomically. A +# complete, well-formed block already present => no-op. Any marker present but +# malformed => warn and leave it for the user (auto-repair could delete +# unrelated rc lines). No markers => append. The full new rc (existing content +# + managed block) is staged to a same-directory temp and committed by an atomic +# rename, so an interruption or I/O failure never leaves a partial/malformed +# block in the live rc. The appended block is guarded by `command -v aenv` so a # missing/broken aenv never emits errors on every shell start. # $1 rc file path _aenv_cc_put_zsh_rc() { @@ -90,16 +139,21 @@ _aenv_cc_put_zsh_rc() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi - # Start the block on its own line only when the rc file is non-empty and - # does not already end with a newline; this avoids leaving a stray blank - # line behind after uninstall. - local leader="" last_byte - if [[ -s "$rc" ]]; then - last_byte=$(tail -c 1 "$rc" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - [[ "$last_byte" == "0a" ]] || leader=$'\n' + local target tmp last_byte + target="$(_aenv_cc_resolve "$rc")" + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 + return 0 + } + # Stage existing content first (guarded), then add a separating newline if + # the existing content did not end in one, then the managed block. Each step + # returns nonzero on I/O failure so we never commit a partial result. + if [[ -s "$target" ]]; then + cat "$target" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } + last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } fi if ! { - printf '%s' "$leader" printf '# >>> aenv completion >>>\n' printf 'if command -v aenv >/dev/null 2>&1; then\n' printf 'autoload -Uz compinit && compinit\n' @@ -107,16 +161,21 @@ _aenv_cc_put_zsh_rc() { printf 'eval "$(aenv completion zsh)"\n' printf 'fi\n' printf '# <<< aenv completion <<<\n' - } >> "$rc" 2>/dev/null; then - printf 'warn: aenv completion: could not append to %s\n' "$rc" >&2 + } >> "$tmp" 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 + return 0 fi + _aenv_cc_commit "$tmp" "$rc" || { + rm -f "$tmp" 2>/dev/null + printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 + } } # Generate the static zsh completion into a site-functions dir. $2 is the # just-installed aenv binary (preferred over whatever is on PATH, which may be -# stale or absent). Generation goes to a temp file in the destination dir and -# is atomically renamed on success, so a failure never truncates an existing -# valid completion file. +# stale or absent). Generation goes through `_aenv_cc_commit`, so a failure or +# empty output never replaces an existing valid completion file. # $1 destination _aenv path # $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { @@ -136,26 +195,27 @@ _aenv_cc_put_zsh_static() { return 0 fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || tmp="$(mktemp 2>/dev/null)" || { - printf 'warn: aenv completion: mktemp failed; skipping static zsh file %s\n' "$path" >&2 + tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 return 0 } - if "${gen[@]}" > "$tmp" 2>/dev/null; then - chmod 0644 "$tmp" 2>/dev/null || true - if mv -f "$tmp" "$path" 2>/dev/null; then - return 0 - fi + # Require non-empty output: a broken aenv that exits 0 with no bytes must not + # erase a working completion via the atomic rename. + if "${gen[@]}" > "$tmp" 2>/dev/null && [[ -s "$tmp" ]] && _aenv_cc_commit "$tmp" "$path"; then + chmod 0644 "$path" 2>/dev/null || true + return 0 fi rm -f "$tmp" 2>/dev/null || true # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed; skipping %s\n' "$path" >&2 + printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 } # Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered/orphan) block. The awk output -# is validated and the rc rewritten in place (cat onto the rc) so the rc's -# inode, mode, ownership, and — for a symlinked rc — the link target are -# preserved; a failed/partial awk never reaches the live rc. +# file with a malformed (partial/nested/reordered/orphan) block. The rewrite is +# staged to a same-directory temp and committed by an atomic rename via +# `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a symlinked rc) +# the link itself are preserved and a failed/partial awk never reaches the live +# file. # $1 rc file path _aenv_cc_rm_zsh_rc() { local rc="$1" @@ -165,13 +225,17 @@ _aenv_cc_rm_zsh_rc() { printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 return 0 fi - local tmp - tmp="$(mktemp 2>/dev/null)" || { - printf 'warn: aenv completion: mktemp failed; leaving %s untouched\n' "$rc" >&2 + local target tmp + target="$(_aenv_cc_resolve "$rc")" + # Temp in the rc's own directory so the rename is atomic (same filesystem). + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 return 0 } - if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$rc" > "$tmp" 2>/dev/null && cat "$tmp" > "$rc" 2>/dev/null; then - rm -f "$tmp" 2>/dev/null || true + # awk's exit status is the signal: on success its output (possibly empty if + # the rc held only the managed block) is the correct new content; on failure + # (read/parse error) it is left partial and we never commit it. + if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then return 0 fi rm -f "$tmp" 2>/dev/null || true @@ -231,8 +295,11 @@ aenv_completion_install() { fi if [[ "$action" == "install" ]]; then - _aenv_cc_put "$bash_file" 0644 'source <(aenv completion bash)' - _aenv_cc_put "$fish_file" 0644 'aenv completion fish | source' + # bash/fish loaders guard on aenv presence so a missing/uninstalled aenv + # is silent rather than erroring on every shell start (matches the zsh + # rc-snippet's `command -v aenv` guard). + _aenv_cc_put "$bash_file" 0644 'command -v aenv >/dev/null 2>&1 && source <(aenv completion bash)' + _aenv_cc_put "$fish_file" 0644 'type -q aenv; and aenv completion fish | source' if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_put_zsh_rc "$zsh_file" else From 5768eedaeffa9d1b08118adaf4db119d82c457cf Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sun, 2 Aug 2026 10:56:59 +0200 Subject: [PATCH 16/23] Update crates/aenv/src/commands/completion.rs Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- crates/aenv/src/commands/completion.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index a7dfc420..2adb05f8 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -73,14 +73,22 @@ mod tests { /// Writer that always fails with a configured error kind, for exercising /// `write_completion`'s error branches. - struct FailingWriter(std::io::ErrorKind); + struct FailingWriter { + kind: std::io::ErrorKind, + fail_on_flush: bool, + } impl std::io::Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> std::io::Result { - Err(std::io::Error::from(self.0)) + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.fail_on_flush { + Ok(buf.len()) + } else { + Err(std::io::Error::from(self.kind)) + } } + fn flush(&mut self) -> std::io::Result<()> { - Err(std::io::Error::from(self.0)) + Err(std::io::Error::from(self.kind)) } } From 2fa21b3b4952386d87d26f5e3b676d00822696ea Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sun, 2 Aug 2026 10:57:34 +0200 Subject: [PATCH 17/23] Update scripts/tests/verify-shell-completion.sh Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/tests/verify-shell-completion.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index da33fd7e..9d06295c 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -214,7 +214,10 @@ assert_rc_clean "$zsrc" echo "==> user mode with unset HOME warns and skips without aborting" home_unset_out="$tmp_root/home-unset.out" env -u HOME bash "$helper" install --user >"$home_unset_out" 2>&1 \ - || fail "helper aborted with --user under unset HOME" -grep -q 'HOME is unset' "$home_unset_out" || fail "expected a HOME-unset warning" + || fail "helper aborted during install --user with HOME unset" +grep -q 'HOME is unset' "$home_unset_out" || fail "expected a HOME-unset warning during install" +env -u HOME bash "$helper" uninstall --user >"$home_unset_out" 2>&1 \ + || fail "helper aborted during uninstall --user with HOME unset" +grep -q 'HOME is unset' "$home_unset_out" || fail "expected a HOME-unset warning during uninstall" echo "==> all shell-completion checks passed" From a9078f3e1aa66309749b9428732279ef3c72ecac Mon Sep 17 00:00:00 2001 From: Balachandar Ramakrishnan Date: Sun, 2 Aug 2026 10:57:49 +0200 Subject: [PATCH 18/23] Update scripts/tests/verify-shell-completion.sh Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/tests/verify-shell-completion.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index 9d06295c..278a2163 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -185,7 +185,12 @@ echo '# pre-existing valid zsh completion' > "$sys_prefix/share/zsh/site-functio HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ || fail "helper aborted when aenv completion zsh exits nonzero" [[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked (regression: fell back to PATH/no-aenv branch)" -assert_contains "$sys_prefix/share/zsh/site-functions/_aenv" '# pre-existing valid zsh completion' +cp "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv.orig" +HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ + || fail "helper aborted when aenv completion zsh exits nonzero" +[[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked (regression: fell back to PATH/no-aenv branch)" +cmp -s "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv.orig" \ + || fail "failed generation modified the existing _aenv" # Exactly one file in the site-functions dir (no leftover .XXXXXX temp). leftovers=( "$sys_prefix/share/zsh/site-functions"/* ) [[ "${#leftovers[@]}" -eq 1 ]] || fail "expected no temp leftovers, found: ${leftovers[*]}" From 8eb8c91bf84a03a1500bedf575e4ebc0993dac4d Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sun, 2 Aug 2026 18:19:21 +0200 Subject: [PATCH 19/23] fix(cli): propagate ownership-marker/upgrade rewrite + fix #compdef + empty-output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagate the comprehensive block rewrite (already in install.sh from the maintainer's edits) across shell-completion.sh and install-cli.sh so all three inlined copies stay byte-identical, and fix two regressions introduced by that rewrite: - Static zsh completion: the ownership marker was PREPENDED, which pushes the generated `#compdef aenv` off line 1 — zsh only loads a function from site-functions when #compdef is the first line, so this would silently break zsh completion. Append the marker as a trailing comment instead. - Empty-output guard: with the marker appended, the generated temp was always non-empty, so the `[[ -s $tmp ]]` check no longer rejected a broken aenv that exits 0 with no bytes — it would replace a valid _aenv with just the marker. Validate the generated BODY for non-emptiness before adding the marker. - _aenv_cc_owns now grep-matches the marker anywhere (line 1 for bash/fish stubs, trailing comment for the static zsh file). Tests: add empty-output preservation, #compdef-first-line + marker presence, and uninstall-leaves-non-aenv-files-untouched cases (the ownership-marker contract). Block also brings (from the maintainer's rewrite): portable symlink resolution (no readlink -f dependency), resolve-before-mktemp (atomic across symlinks), best-effort flock around the rc read-modify-write, stale-block in-place upgrade, compdef-already-defined guard, and honest ACL/xattr non-preservation note. --- scripts/install-cli.sh | 313 ++++++++++++++++++----- scripts/install.sh | 313 ++++++++++++++++++----- scripts/shell-completion.sh | 313 ++++++++++++++++++----- scripts/tests/verify-shell-completion.sh | 50 +++- 4 files changed, 798 insertions(+), 191 deletions(-) diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 394fd03d..d54626da 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -74,9 +74,20 @@ run_privileged() { # All filesystem writes flow through ONE atomic-commit primitive # (`_aenv_cc_commit`): the new content is staged to a temp file created IN the # destination directory (same filesystem => an atomic rename), the destination's -# symlink is resolved (so the link is preserved, not replaced) and its mode is -# copied. This guarantees every write site shares the same atomicity / symlink / +# symlink is resolved (so the link is preserved, not replaced), and its mode +# (and, when running as root, ownership) is copied onto the temp file first. +# This guarantees every write site shares the same atomicity / symlink / # metadata properties, so the pattern cannot drift between functions. +# +# NOTE: this does NOT preserve ACLs, extended attributes, or security labels +# (SELinux/AppArmor contexts) — only the POSIX mode bits, and ownership when +# we are root. Callers writing to files that carry such metadata should not +# assume it survives the rename. +# +# Every generated file/block also carries an aenv ownership marker so +# `uninstall` never deletes a file it did not create (see _AENV_CC_MARKER). + +_AENV_CC_MARKER="# managed by aenv-installer; do not edit (remove the whole file to opt out)" # Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the # mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, @@ -86,35 +97,65 @@ _aenv_cc_mode_octal() { } # Resolve $1 to the real file path it refers to when it is a symlink, so writes -# land on the target and preserve the link rather than replacing it. Falls back -# to $1 when readlink is unavailable or $1 is not a symlink. +# land on the target and preserve the link rather than replacing it. +# - Not a symlink: prints $1, returns 0. +# - Symlink, resolvable: prints the resolved absolute path, returns 0. +# - Symlink, NOT resolvable (broken link, no readlink at all): prints +# nothing and returns 1. Callers MUST check the return status and refuse +# to write rather than falling back to $1 — writing to $1 in that case +# would replace the symlink itself, silently breaking the "preserve the +# link" guarantee this whole module advertises. _aenv_cc_resolve() { if [[ -L "$1" ]]; then - readlink -f "$1" 2>/dev/null || printf '%s' "$1" - else - printf '%s' "$1" + local resolved + if resolved="$(readlink -f "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then + printf '%s' "$resolved" + return 0 + fi + # Portable one-hop fallback for platforms without GNU `readlink -f` + # (e.g. some BSD/macOS readlink builds). Only handles a single-level + # symlink, which covers the common case; anything more exotic + # (relative multi-hop chains) is treated as unresolvable. + if resolved="$(readlink "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then + [[ "$resolved" = /* ]] || resolved="${1%/*}/$resolved" + printf '%s' "$resolved" + return 0 + fi + return 1 fi + printf '%s' "$1" + return 0 } # Atomically publish a staged temp file as . -# $1 temp file path — MUST live in the same directory as (caller's job) -# so the final rename is atomic and not a cross-filesystem copy+delete. +# $1 temp file path — MUST live in the same directory as 's RESOLVED +# target (caller's job) so the final rename is atomic and not a +# cross-filesystem copy+delete. # $2 destination path (possibly a symlink; its target is replaced, the link -# itself is preserved). The destination's current mode is copied onto the -# temp first (0644 default for a new file). -# Returns nonzero on failure; the caller is responsible for cleaning up the temp. +# itself is preserved). The destination's current mode — and, when +# running as root, its ownership — is copied onto the temp first (0644 +# default for a new file). +# Returns nonzero on failure (including an unresolvable symlink); the caller +# is responsible for cleaning up the temp in that case. _aenv_cc_commit() { local tmp="$1" dest="$2" local target - target="$(_aenv_cc_resolve "$dest")" + if ! target="$(_aenv_cc_resolve "$dest")" || [[ -z "$target" ]]; then + return 1 + fi chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + if [[ $EUID -eq 0 && -e "$target" ]]; then + chown --reference="$target" "$tmp" 2>/dev/null || true + fi mv -f "$tmp" "$target" 2>/dev/null } -# Write a single completion loader file atomically. Non-fatal on I/O errors. +# Write a single completion loader file atomically, prefixed with the aenv +# ownership marker so a later uninstall can verify it still owns the file +# before deleting it. Non-fatal on I/O errors. # $1 destination path # $2 file mode (e.g. 0644) -# $3 loader content (single line; the loaders are one-liners by design) +# $3 loader content (one or more lines; the marker is prepended) _aenv_cc_put() { local path="$1" mode="$2" content="$3" local dir="${path%/*}" @@ -122,12 +163,23 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 + return 0 + fi + local target_dir="${target%/*}" + if ! mkdir -p "$target_dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 + return 0 + fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 return 0 } - if printf '%s\n' "$content" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$path"; then + if { printf '%s\n' "$_AENV_CC_MARKER"; printf '%s\n' "$content"; } > "$tmp" 2>/dev/null \ + && _aenv_cc_commit "$tmp" "$path"; then chmod "$mode" "$path" 2>/dev/null || true return 0 fi @@ -149,31 +201,120 @@ _aenv_cc_rc_well_formed() { ' "$1" 2>/dev/null } -# Append the regenerating zsh rc-snippet, idempotently and atomically. A -# complete, well-formed block already present => no-op. Any marker present but -# malformed => warn and leave it for the user (auto-repair could delete -# unrelated rc lines). No markers => append. The full new rc (existing content -# + managed block) is staged to a same-directory temp and committed by an atomic -# rename, so an interruption or I/O failure never leaves a partial/malformed -# block in the live rc. The appended block is guarded by `command -v aenv` so a -# missing/broken aenv never emits errors on every shell start. +# The canonical managed block content (including its start/end markers). +# Single source of truth used both to write a fresh block and to detect a +# stale one on reinstall/upgrade. +# +# The compinit call is now guarded on `compdef` already being defined, so a +# framework (oh-my-zsh, prezto, etc.) or an earlier rc section that already +# ran compinit is not forced to pay for a second (relatively expensive) run +# on every shell start. +_aenv_cc_zsh_block_canonical() { + printf '# >>> aenv completion >>>\n' + printf 'if command -v aenv >/dev/null 2>&1; then\n' + printf 'type compdef >/dev/null 2>&1 || { autoload -Uz compinit && compinit; }\n' + # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash + printf 'eval "$(aenv completion zsh)"\n' + printf 'fi\n' + printf '# <<< aenv completion <<<\n' +} + +# Print the currently-installed managed block (markers included) from $1, or +# nothing if there isn't one. Used to detect a stale block on reinstall. +_aenv_cc_zsh_block_current() { + awk ' + /^# >>> aenv completion >>>$/ { f = 1 } + f { print } + /^# <<< aenv completion <<<$/ { f = 0 } + ' "$1" 2>/dev/null +} + +# Serialize the full read-check-write of a zsh rc file with an flock-based +# lock so two concurrent installer runs (or install racing uninstall) cannot +# both observe "no marker" and both append, or otherwise interleave into a +# malformed/duplicated block. Best-effort: if flock isn't available we fall +# back to running unlocked rather than failing the (best-effort) completion +# install outright. # $1 rc file path -_aenv_cc_put_zsh_rc() { +# $2... function name + args to run inside the lock +_aenv_cc_with_zsh_lock() { + local rc="$1"; shift + if command -v flock >/dev/null 2>&1; then + ( + flock -w 10 200 || { + printf 'warn: aenv completion: could not lock %s (timed out); skipping\n' "$rc" >&2 + exit 0 + } + "$@" + ) 200>"${rc}.aenv-lock" 2>&2 + else + "$@" + fi +} + +# Append (or, on upgrade, in-place replace) the regenerating zsh rc-snippet, +# idempotently and atomically. +# - No markers present: append the canonical block. +# - Well-formed block present, contents match canonical: no-op. +# - Well-formed block present, contents differ (e.g. upgrade changed the +# snippet): replace just the block, byte-for-byte, leaving everything +# else in the file untouched. +# - Malformed block present: warn and leave it for the user (auto-repair +# could delete unrelated rc lines). +# The full new rc (existing content, possibly with the block replaced, or +# + the appended block) is staged to a same-directory temp and committed by +# an atomic rename, so an interruption or I/O failure never leaves a +# partial/malformed block in the live rc. +# $1 rc file path +_aenv_cc_put_zsh_rc_impl() { local rc="$1" local dir="${rc%/*}" + local canonical + canonical="$(_aenv_cc_zsh_block_canonical)" + if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then - if _aenv_cc_rc_well_formed "$rc"; then - return 0 # idempotent: a complete managed block already exists + if ! _aenv_cc_rc_well_formed "$rc"; then + printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + return 0 + fi + local current + current="$(_aenv_cc_zsh_block_current "$rc")" + if [[ "$current" == "$canonical" ]]; then + return 0 # idempotent: a complete, up-to-date managed block already exists fi - printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + # Stale block: rewrite just that span in place, atomically. + local target tmp + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 + return 0 + } + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 + return 0 + } + if awk -v block="$canonical" ' + BEGIN { in_block = 0 } + /^# >>> aenv completion >>>$/ { print block; in_block = 1; next } + /^# <<< aenv completion <<<$/ { in_block = 0; next } + in_block { next } + { print } + ' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not update stale block in %s\n' "$rc" >&2 return 0 fi + if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi local target tmp last_byte - target="$(_aenv_cc_resolve "$rc")" + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 + return 0 + } tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 return 0 @@ -186,15 +327,7 @@ _aenv_cc_put_zsh_rc() { last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } fi - if ! { - printf '# >>> aenv completion >>>\n' - printf 'if command -v aenv >/dev/null 2>&1; then\n' - printf 'autoload -Uz compinit && compinit\n' - # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash - printf 'eval "$(aenv completion zsh)"\n' - printf 'fi\n' - printf '# <<< aenv completion <<<\n' - } >> "$tmp" 2>/dev/null; then + if ! printf '%s\n' "$canonical" >> "$tmp" 2>/dev/null; then rm -f "$tmp" 2>/dev/null printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 return 0 @@ -205,10 +338,16 @@ _aenv_cc_put_zsh_rc() { } } -# Generate the static zsh completion into a site-functions dir. $2 is the -# just-installed aenv binary (preferred over whatever is on PATH, which may be -# stale or absent). Generation goes through `_aenv_cc_commit`, so a failure or -# empty output never replaces an existing valid completion file. +_aenv_cc_put_zsh_rc() { + _aenv_cc_with_zsh_lock "$1" _aenv_cc_put_zsh_rc_impl "$1" +} + +# Generate the static zsh completion into a site-functions dir, prefixed with +# the aenv ownership marker (as a comment) so uninstall can verify ownership. +# $2 is the just-installed aenv binary (preferred over whatever is on PATH, +# which may be stale or absent). Generation goes through `_aenv_cc_commit`, +# so a failure or empty output never replaces an existing valid completion +# file. # $1 destination _aenv path # $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { @@ -227,30 +366,74 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 return 0 fi + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 + return 0 + fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 return 0 } - # Require non-empty output: a broken aenv that exits 0 with no bytes must not - # erase a working completion via the atomic rename. - if "${gen[@]}" > "$tmp" 2>/dev/null && [[ -s "$tmp" ]] && _aenv_cc_commit "$tmp" "$path"; then + # Generate the completion body first and require it to be non-empty: a + # broken aenv that exits 0 with no bytes must not erase a working completion + # via the atomic rename. The ownership marker is appended AFTER this check + # (as a trailing comment) so the generated #compdef stays on line 1 — zsh + # only loads the function if #compdef is the first line. + if ! "${gen[@]}" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then + rm -f "$tmp" 2>/dev/null || true + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 + return 0 + fi + printf '%s\n' "$_AENV_CC_MARKER" >> "$tmp" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 + return 0 + } + if _aenv_cc_commit "$tmp" "$path"; then chmod 0644 "$path" 2>/dev/null || true return 0 fi rm -f "$tmp" 2>/dev/null || true - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 + printf 'warn: aenv completion: could not commit static zsh file %s\n' "$path" >&2 } -# Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered/orphan) block. The rewrite is -# staged to a same-directory temp and committed by an atomic rename via -# `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a symlinked rc) -# the link itself are preserved and a failed/partial awk never reaches the live -# file. +# True if $1 is a plain file that starts with the aenv ownership marker — +# i.e. a file this installer created and is safe to remove. A pre-existing, +# hand-written, or package-manager-owned completion file at the same +# conventional path will NOT match, and is left alone. +_aenv_cc_owns() { + [[ -f "$1" ]] || return 1 + # Match the marker anywhere: bash/fish stubs carry it on line 1, while the + # static zsh file carries it as a trailing comment (its #compdef must stay + # on line 1 for zsh to load the function). + grep -qF -- "$_AENV_CC_MARKER" "$1" 2>/dev/null +} + +# Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it +# untouched so a user's own completion file, or one now owned by a package +# manager, is never silently deleted. +_aenv_cc_rm_owned() { + local path="$1" + [[ -e "$path" ]] || return 0 + if _aenv_cc_owns "$path"; then + rm -f "$path" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 + else + printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 + fi +} + +# Remove every well-formed aenv marker block from the zsh rc. Refuses to touch +# a file with a malformed (partial/nested/reordered/orphan) block. The +# rewrite is staged to a same-directory temp and committed by an atomic +# rename via `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a +# symlinked rc) the link itself are preserved, and a failed/partial awk never +# reaches the live file. Locked the same way as install to avoid racing a +# concurrent install/uninstall or hand-edit. # $1 rc file path -_aenv_cc_rm_zsh_rc() { +_aenv_cc_rm_zsh_rc_impl() { local rc="$1" [[ -f "$rc" ]] || return 0 grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 @@ -259,15 +442,14 @@ _aenv_cc_rm_zsh_rc() { return 0 fi local target tmp - target="$(_aenv_cc_resolve "$rc")" - # Temp in the rc's own directory so the rename is atomic (same filesystem). + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; leaving it untouched\n' "$rc" >&2 + return 0 + } tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 return 0 } - # awk's exit status is the signal: on success its output (possibly empty if - # the rc held only the managed block) is the correct new content; on failure - # (read/parse error) it is left partial and we never commit it. if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then return 0 fi @@ -275,6 +457,10 @@ _aenv_cc_rm_zsh_rc() { printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 } +_aenv_cc_rm_zsh_rc() { + _aenv_cc_with_zsh_lock "$1" _aenv_cc_rm_zsh_rc_impl "$1" +} + # Install or remove the aenv shell-completion loaders. # # aenv_completion_install install [--prefix=

] [--user] @@ -339,11 +525,12 @@ aenv_completion_install() { _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" fi else - rm -f "$bash_file" "$fish_file" 2>/dev/null || true + _aenv_cc_rm_owned "$bash_file" + _aenv_cc_rm_owned "$fish_file" if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_rm_zsh_rc "$zsh_file" else - rm -f "$zsh_file" 2>/dev/null || true + _aenv_cc_rm_owned "$zsh_file" fi fi return 0 diff --git a/scripts/install.sh b/scripts/install.sh index cbe068c6..7f7f5363 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -70,9 +70,20 @@ curl_get() { # All filesystem writes flow through ONE atomic-commit primitive # (`_aenv_cc_commit`): the new content is staged to a temp file created IN the # destination directory (same filesystem => an atomic rename), the destination's -# symlink is resolved (so the link is preserved, not replaced) and its mode is -# copied. This guarantees every write site shares the same atomicity / symlink / +# symlink is resolved (so the link is preserved, not replaced), and its mode +# (and, when running as root, ownership) is copied onto the temp file first. +# This guarantees every write site shares the same atomicity / symlink / # metadata properties, so the pattern cannot drift between functions. +# +# NOTE: this does NOT preserve ACLs, extended attributes, or security labels +# (SELinux/AppArmor contexts) — only the POSIX mode bits, and ownership when +# we are root. Callers writing to files that carry such metadata should not +# assume it survives the rename. +# +# Every generated file/block also carries an aenv ownership marker so +# `uninstall` never deletes a file it did not create (see _AENV_CC_MARKER). + +_AENV_CC_MARKER="# managed by aenv-installer; do not edit (remove the whole file to opt out)" # Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the # mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, @@ -82,35 +93,65 @@ _aenv_cc_mode_octal() { } # Resolve $1 to the real file path it refers to when it is a symlink, so writes -# land on the target and preserve the link rather than replacing it. Falls back -# to $1 when readlink is unavailable or $1 is not a symlink. +# land on the target and preserve the link rather than replacing it. +# - Not a symlink: prints $1, returns 0. +# - Symlink, resolvable: prints the resolved absolute path, returns 0. +# - Symlink, NOT resolvable (broken link, no readlink at all): prints +# nothing and returns 1. Callers MUST check the return status and refuse +# to write rather than falling back to $1 — writing to $1 in that case +# would replace the symlink itself, silently breaking the "preserve the +# link" guarantee this whole module advertises. _aenv_cc_resolve() { if [[ -L "$1" ]]; then - readlink -f "$1" 2>/dev/null || printf '%s' "$1" - else - printf '%s' "$1" + local resolved + if resolved="$(readlink -f "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then + printf '%s' "$resolved" + return 0 + fi + # Portable one-hop fallback for platforms without GNU `readlink -f` + # (e.g. some BSD/macOS readlink builds). Only handles a single-level + # symlink, which covers the common case; anything more exotic + # (relative multi-hop chains) is treated as unresolvable. + if resolved="$(readlink "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then + [[ "$resolved" = /* ]] || resolved="${1%/*}/$resolved" + printf '%s' "$resolved" + return 0 + fi + return 1 fi + printf '%s' "$1" + return 0 } # Atomically publish a staged temp file as . -# $1 temp file path — MUST live in the same directory as (caller's job) -# so the final rename is atomic and not a cross-filesystem copy+delete. +# $1 temp file path — MUST live in the same directory as 's RESOLVED +# target (caller's job) so the final rename is atomic and not a +# cross-filesystem copy+delete. # $2 destination path (possibly a symlink; its target is replaced, the link -# itself is preserved). The destination's current mode is copied onto the -# temp first (0644 default for a new file). -# Returns nonzero on failure; the caller is responsible for cleaning up the temp. +# itself is preserved). The destination's current mode — and, when +# running as root, its ownership — is copied onto the temp first (0644 +# default for a new file). +# Returns nonzero on failure (including an unresolvable symlink); the caller +# is responsible for cleaning up the temp in that case. _aenv_cc_commit() { local tmp="$1" dest="$2" local target - target="$(_aenv_cc_resolve "$dest")" + if ! target="$(_aenv_cc_resolve "$dest")" || [[ -z "$target" ]]; then + return 1 + fi chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + if [[ $EUID -eq 0 && -e "$target" ]]; then + chown --reference="$target" "$tmp" 2>/dev/null || true + fi mv -f "$tmp" "$target" 2>/dev/null } -# Write a single completion loader file atomically. Non-fatal on I/O errors. +# Write a single completion loader file atomically, prefixed with the aenv +# ownership marker so a later uninstall can verify it still owns the file +# before deleting it. Non-fatal on I/O errors. # $1 destination path # $2 file mode (e.g. 0644) -# $3 loader content (single line; the loaders are one-liners by design) +# $3 loader content (one or more lines; the marker is prepended) _aenv_cc_put() { local path="$1" mode="$2" content="$3" local dir="${path%/*}" @@ -118,12 +159,23 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 + return 0 + fi + local target_dir="${target%/*}" + if ! mkdir -p "$target_dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 + return 0 + fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 return 0 } - if printf '%s\n' "$content" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$path"; then + if { printf '%s\n' "$_AENV_CC_MARKER"; printf '%s\n' "$content"; } > "$tmp" 2>/dev/null \ + && _aenv_cc_commit "$tmp" "$path"; then chmod "$mode" "$path" 2>/dev/null || true return 0 fi @@ -145,31 +197,120 @@ _aenv_cc_rc_well_formed() { ' "$1" 2>/dev/null } -# Append the regenerating zsh rc-snippet, idempotently and atomically. A -# complete, well-formed block already present => no-op. Any marker present but -# malformed => warn and leave it for the user (auto-repair could delete -# unrelated rc lines). No markers => append. The full new rc (existing content -# + managed block) is staged to a same-directory temp and committed by an atomic -# rename, so an interruption or I/O failure never leaves a partial/malformed -# block in the live rc. The appended block is guarded by `command -v aenv` so a -# missing/broken aenv never emits errors on every shell start. +# The canonical managed block content (including its start/end markers). +# Single source of truth used both to write a fresh block and to detect a +# stale one on reinstall/upgrade. +# +# The compinit call is now guarded on `compdef` already being defined, so a +# framework (oh-my-zsh, prezto, etc.) or an earlier rc section that already +# ran compinit is not forced to pay for a second (relatively expensive) run +# on every shell start. +_aenv_cc_zsh_block_canonical() { + printf '# >>> aenv completion >>>\n' + printf 'if command -v aenv >/dev/null 2>&1; then\n' + printf 'type compdef >/dev/null 2>&1 || { autoload -Uz compinit && compinit; }\n' + # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash + printf 'eval "$(aenv completion zsh)"\n' + printf 'fi\n' + printf '# <<< aenv completion <<<\n' +} + +# Print the currently-installed managed block (markers included) from $1, or +# nothing if there isn't one. Used to detect a stale block on reinstall. +_aenv_cc_zsh_block_current() { + awk ' + /^# >>> aenv completion >>>$/ { f = 1 } + f { print } + /^# <<< aenv completion <<<$/ { f = 0 } + ' "$1" 2>/dev/null +} + +# Serialize the full read-check-write of a zsh rc file with an flock-based +# lock so two concurrent installer runs (or install racing uninstall) cannot +# both observe "no marker" and both append, or otherwise interleave into a +# malformed/duplicated block. Best-effort: if flock isn't available we fall +# back to running unlocked rather than failing the (best-effort) completion +# install outright. # $1 rc file path -_aenv_cc_put_zsh_rc() { +# $2... function name + args to run inside the lock +_aenv_cc_with_zsh_lock() { + local rc="$1"; shift + if command -v flock >/dev/null 2>&1; then + ( + flock -w 10 200 || { + printf 'warn: aenv completion: could not lock %s (timed out); skipping\n' "$rc" >&2 + exit 0 + } + "$@" + ) 200>"${rc}.aenv-lock" 2>&2 + else + "$@" + fi +} + +# Append (or, on upgrade, in-place replace) the regenerating zsh rc-snippet, +# idempotently and atomically. +# - No markers present: append the canonical block. +# - Well-formed block present, contents match canonical: no-op. +# - Well-formed block present, contents differ (e.g. upgrade changed the +# snippet): replace just the block, byte-for-byte, leaving everything +# else in the file untouched. +# - Malformed block present: warn and leave it for the user (auto-repair +# could delete unrelated rc lines). +# The full new rc (existing content, possibly with the block replaced, or +# + the appended block) is staged to a same-directory temp and committed by +# an atomic rename, so an interruption or I/O failure never leaves a +# partial/malformed block in the live rc. +# $1 rc file path +_aenv_cc_put_zsh_rc_impl() { local rc="$1" local dir="${rc%/*}" + local canonical + canonical="$(_aenv_cc_zsh_block_canonical)" + if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then - if _aenv_cc_rc_well_formed "$rc"; then - return 0 # idempotent: a complete managed block already exists + if ! _aenv_cc_rc_well_formed "$rc"; then + printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + return 0 + fi + local current + current="$(_aenv_cc_zsh_block_current "$rc")" + if [[ "$current" == "$canonical" ]]; then + return 0 # idempotent: a complete, up-to-date managed block already exists fi - printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + # Stale block: rewrite just that span in place, atomically. + local target tmp + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 + return 0 + } + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 + return 0 + } + if awk -v block="$canonical" ' + BEGIN { in_block = 0 } + /^# >>> aenv completion >>>$/ { print block; in_block = 1; next } + /^# <<< aenv completion <<<$/ { in_block = 0; next } + in_block { next } + { print } + ' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not update stale block in %s\n' "$rc" >&2 return 0 fi + if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi local target tmp last_byte - target="$(_aenv_cc_resolve "$rc")" + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 + return 0 + } tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 return 0 @@ -182,15 +323,7 @@ _aenv_cc_put_zsh_rc() { last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } fi - if ! { - printf '# >>> aenv completion >>>\n' - printf 'if command -v aenv >/dev/null 2>&1; then\n' - printf 'autoload -Uz compinit && compinit\n' - # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash - printf 'eval "$(aenv completion zsh)"\n' - printf 'fi\n' - printf '# <<< aenv completion <<<\n' - } >> "$tmp" 2>/dev/null; then + if ! printf '%s\n' "$canonical" >> "$tmp" 2>/dev/null; then rm -f "$tmp" 2>/dev/null printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 return 0 @@ -201,10 +334,16 @@ _aenv_cc_put_zsh_rc() { } } -# Generate the static zsh completion into a site-functions dir. $2 is the -# just-installed aenv binary (preferred over whatever is on PATH, which may be -# stale or absent). Generation goes through `_aenv_cc_commit`, so a failure or -# empty output never replaces an existing valid completion file. +_aenv_cc_put_zsh_rc() { + _aenv_cc_with_zsh_lock "$1" _aenv_cc_put_zsh_rc_impl "$1" +} + +# Generate the static zsh completion into a site-functions dir, prefixed with +# the aenv ownership marker (as a comment) so uninstall can verify ownership. +# $2 is the just-installed aenv binary (preferred over whatever is on PATH, +# which may be stale or absent). Generation goes through `_aenv_cc_commit`, +# so a failure or empty output never replaces an existing valid completion +# file. # $1 destination _aenv path # $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { @@ -223,30 +362,74 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 return 0 fi + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 + return 0 + fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 return 0 } - # Require non-empty output: a broken aenv that exits 0 with no bytes must not - # erase a working completion via the atomic rename. - if "${gen[@]}" > "$tmp" 2>/dev/null && [[ -s "$tmp" ]] && _aenv_cc_commit "$tmp" "$path"; then + # Generate the completion body first and require it to be non-empty: a + # broken aenv that exits 0 with no bytes must not erase a working completion + # via the atomic rename. The ownership marker is appended AFTER this check + # (as a trailing comment) so the generated #compdef stays on line 1 — zsh + # only loads the function if #compdef is the first line. + if ! "${gen[@]}" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then + rm -f "$tmp" 2>/dev/null || true + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 + return 0 + fi + printf '%s\n' "$_AENV_CC_MARKER" >> "$tmp" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 + return 0 + } + if _aenv_cc_commit "$tmp" "$path"; then chmod 0644 "$path" 2>/dev/null || true return 0 fi rm -f "$tmp" 2>/dev/null || true - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 + printf 'warn: aenv completion: could not commit static zsh file %s\n' "$path" >&2 } -# Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered/orphan) block. The rewrite is -# staged to a same-directory temp and committed by an atomic rename via -# `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a symlinked rc) -# the link itself are preserved and a failed/partial awk never reaches the live -# file. +# True if $1 is a plain file that starts with the aenv ownership marker — +# i.e. a file this installer created and is safe to remove. A pre-existing, +# hand-written, or package-manager-owned completion file at the same +# conventional path will NOT match, and is left alone. +_aenv_cc_owns() { + [[ -f "$1" ]] || return 1 + # Match the marker anywhere: bash/fish stubs carry it on line 1, while the + # static zsh file carries it as a trailing comment (its #compdef must stay + # on line 1 for zsh to load the function). + grep -qF -- "$_AENV_CC_MARKER" "$1" 2>/dev/null +} + +# Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it +# untouched so a user's own completion file, or one now owned by a package +# manager, is never silently deleted. +_aenv_cc_rm_owned() { + local path="$1" + [[ -e "$path" ]] || return 0 + if _aenv_cc_owns "$path"; then + rm -f "$path" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 + else + printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 + fi +} + +# Remove every well-formed aenv marker block from the zsh rc. Refuses to touch +# a file with a malformed (partial/nested/reordered/orphan) block. The +# rewrite is staged to a same-directory temp and committed by an atomic +# rename via `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a +# symlinked rc) the link itself are preserved, and a failed/partial awk never +# reaches the live file. Locked the same way as install to avoid racing a +# concurrent install/uninstall or hand-edit. # $1 rc file path -_aenv_cc_rm_zsh_rc() { +_aenv_cc_rm_zsh_rc_impl() { local rc="$1" [[ -f "$rc" ]] || return 0 grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 @@ -255,15 +438,14 @@ _aenv_cc_rm_zsh_rc() { return 0 fi local target tmp - target="$(_aenv_cc_resolve "$rc")" - # Temp in the rc's own directory so the rename is atomic (same filesystem). + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; leaving it untouched\n' "$rc" >&2 + return 0 + } tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 return 0 } - # awk's exit status is the signal: on success its output (possibly empty if - # the rc held only the managed block) is the correct new content; on failure - # (read/parse error) it is left partial and we never commit it. if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then return 0 fi @@ -271,6 +453,10 @@ _aenv_cc_rm_zsh_rc() { printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 } +_aenv_cc_rm_zsh_rc() { + _aenv_cc_with_zsh_lock "$1" _aenv_cc_rm_zsh_rc_impl "$1" +} + # Install or remove the aenv shell-completion loaders. # # aenv_completion_install install [--prefix=

] [--user] @@ -335,11 +521,12 @@ aenv_completion_install() { _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" fi else - rm -f "$bash_file" "$fish_file" 2>/dev/null || true + _aenv_cc_rm_owned "$bash_file" + _aenv_cc_rm_owned "$fish_file" if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_rm_zsh_rc "$zsh_file" else - rm -f "$zsh_file" 2>/dev/null || true + _aenv_cc_rm_owned "$zsh_file" fi fi return 0 diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh index eee2e018..873d1cf7 100755 --- a/scripts/shell-completion.sh +++ b/scripts/shell-completion.sh @@ -41,9 +41,20 @@ set -euo pipefail # All filesystem writes flow through ONE atomic-commit primitive # (`_aenv_cc_commit`): the new content is staged to a temp file created IN the # destination directory (same filesystem => an atomic rename), the destination's -# symlink is resolved (so the link is preserved, not replaced) and its mode is -# copied. This guarantees every write site shares the same atomicity / symlink / +# symlink is resolved (so the link is preserved, not replaced), and its mode +# (and, when running as root, ownership) is copied onto the temp file first. +# This guarantees every write site shares the same atomicity / symlink / # metadata properties, so the pattern cannot drift between functions. +# +# NOTE: this does NOT preserve ACLs, extended attributes, or security labels +# (SELinux/AppArmor contexts) — only the POSIX mode bits, and ownership when +# we are root. Callers writing to files that carry such metadata should not +# assume it survives the rename. +# +# Every generated file/block also carries an aenv ownership marker so +# `uninstall` never deletes a file it did not create (see _AENV_CC_MARKER). + +_AENV_CC_MARKER="# managed by aenv-installer; do not edit (remove the whole file to opt out)" # Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the # mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, @@ -53,35 +64,65 @@ _aenv_cc_mode_octal() { } # Resolve $1 to the real file path it refers to when it is a symlink, so writes -# land on the target and preserve the link rather than replacing it. Falls back -# to $1 when readlink is unavailable or $1 is not a symlink. +# land on the target and preserve the link rather than replacing it. +# - Not a symlink: prints $1, returns 0. +# - Symlink, resolvable: prints the resolved absolute path, returns 0. +# - Symlink, NOT resolvable (broken link, no readlink at all): prints +# nothing and returns 1. Callers MUST check the return status and refuse +# to write rather than falling back to $1 — writing to $1 in that case +# would replace the symlink itself, silently breaking the "preserve the +# link" guarantee this whole module advertises. _aenv_cc_resolve() { if [[ -L "$1" ]]; then - readlink -f "$1" 2>/dev/null || printf '%s' "$1" - else - printf '%s' "$1" + local resolved + if resolved="$(readlink -f "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then + printf '%s' "$resolved" + return 0 + fi + # Portable one-hop fallback for platforms without GNU `readlink -f` + # (e.g. some BSD/macOS readlink builds). Only handles a single-level + # symlink, which covers the common case; anything more exotic + # (relative multi-hop chains) is treated as unresolvable. + if resolved="$(readlink "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then + [[ "$resolved" = /* ]] || resolved="${1%/*}/$resolved" + printf '%s' "$resolved" + return 0 + fi + return 1 fi + printf '%s' "$1" + return 0 } # Atomically publish a staged temp file as . -# $1 temp file path — MUST live in the same directory as (caller's job) -# so the final rename is atomic and not a cross-filesystem copy+delete. +# $1 temp file path — MUST live in the same directory as 's RESOLVED +# target (caller's job) so the final rename is atomic and not a +# cross-filesystem copy+delete. # $2 destination path (possibly a symlink; its target is replaced, the link -# itself is preserved). The destination's current mode is copied onto the -# temp first (0644 default for a new file). -# Returns nonzero on failure; the caller is responsible for cleaning up the temp. +# itself is preserved). The destination's current mode — and, when +# running as root, its ownership — is copied onto the temp first (0644 +# default for a new file). +# Returns nonzero on failure (including an unresolvable symlink); the caller +# is responsible for cleaning up the temp in that case. _aenv_cc_commit() { local tmp="$1" dest="$2" local target - target="$(_aenv_cc_resolve "$dest")" + if ! target="$(_aenv_cc_resolve "$dest")" || [[ -z "$target" ]]; then + return 1 + fi chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + if [[ $EUID -eq 0 && -e "$target" ]]; then + chown --reference="$target" "$tmp" 2>/dev/null || true + fi mv -f "$tmp" "$target" 2>/dev/null } -# Write a single completion loader file atomically. Non-fatal on I/O errors. +# Write a single completion loader file atomically, prefixed with the aenv +# ownership marker so a later uninstall can verify it still owns the file +# before deleting it. Non-fatal on I/O errors. # $1 destination path # $2 file mode (e.g. 0644) -# $3 loader content (single line; the loaders are one-liners by design) +# $3 loader content (one or more lines; the marker is prepended) _aenv_cc_put() { local path="$1" mode="$2" content="$3" local dir="${path%/*}" @@ -89,12 +130,23 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 + return 0 + fi + local target_dir="${target%/*}" + if ! mkdir -p "$target_dir" 2>/dev/null; then + printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 + return 0 + fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 return 0 } - if printf '%s\n' "$content" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$path"; then + if { printf '%s\n' "$_AENV_CC_MARKER"; printf '%s\n' "$content"; } > "$tmp" 2>/dev/null \ + && _aenv_cc_commit "$tmp" "$path"; then chmod "$mode" "$path" 2>/dev/null || true return 0 fi @@ -116,31 +168,120 @@ _aenv_cc_rc_well_formed() { ' "$1" 2>/dev/null } -# Append the regenerating zsh rc-snippet, idempotently and atomically. A -# complete, well-formed block already present => no-op. Any marker present but -# malformed => warn and leave it for the user (auto-repair could delete -# unrelated rc lines). No markers => append. The full new rc (existing content -# + managed block) is staged to a same-directory temp and committed by an atomic -# rename, so an interruption or I/O failure never leaves a partial/malformed -# block in the live rc. The appended block is guarded by `command -v aenv` so a -# missing/broken aenv never emits errors on every shell start. +# The canonical managed block content (including its start/end markers). +# Single source of truth used both to write a fresh block and to detect a +# stale one on reinstall/upgrade. +# +# The compinit call is now guarded on `compdef` already being defined, so a +# framework (oh-my-zsh, prezto, etc.) or an earlier rc section that already +# ran compinit is not forced to pay for a second (relatively expensive) run +# on every shell start. +_aenv_cc_zsh_block_canonical() { + printf '# >>> aenv completion >>>\n' + printf 'if command -v aenv >/dev/null 2>&1; then\n' + printf 'type compdef >/dev/null 2>&1 || { autoload -Uz compinit && compinit; }\n' + # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash + printf 'eval "$(aenv completion zsh)"\n' + printf 'fi\n' + printf '# <<< aenv completion <<<\n' +} + +# Print the currently-installed managed block (markers included) from $1, or +# nothing if there isn't one. Used to detect a stale block on reinstall. +_aenv_cc_zsh_block_current() { + awk ' + /^# >>> aenv completion >>>$/ { f = 1 } + f { print } + /^# <<< aenv completion <<<$/ { f = 0 } + ' "$1" 2>/dev/null +} + +# Serialize the full read-check-write of a zsh rc file with an flock-based +# lock so two concurrent installer runs (or install racing uninstall) cannot +# both observe "no marker" and both append, or otherwise interleave into a +# malformed/duplicated block. Best-effort: if flock isn't available we fall +# back to running unlocked rather than failing the (best-effort) completion +# install outright. # $1 rc file path -_aenv_cc_put_zsh_rc() { +# $2... function name + args to run inside the lock +_aenv_cc_with_zsh_lock() { + local rc="$1"; shift + if command -v flock >/dev/null 2>&1; then + ( + flock -w 10 200 || { + printf 'warn: aenv completion: could not lock %s (timed out); skipping\n' "$rc" >&2 + exit 0 + } + "$@" + ) 200>"${rc}.aenv-lock" 2>&2 + else + "$@" + fi +} + +# Append (or, on upgrade, in-place replace) the regenerating zsh rc-snippet, +# idempotently and atomically. +# - No markers present: append the canonical block. +# - Well-formed block present, contents match canonical: no-op. +# - Well-formed block present, contents differ (e.g. upgrade changed the +# snippet): replace just the block, byte-for-byte, leaving everything +# else in the file untouched. +# - Malformed block present: warn and leave it for the user (auto-repair +# could delete unrelated rc lines). +# The full new rc (existing content, possibly with the block replaced, or +# + the appended block) is staged to a same-directory temp and committed by +# an atomic rename, so an interruption or I/O failure never leaves a +# partial/malformed block in the live rc. +# $1 rc file path +_aenv_cc_put_zsh_rc_impl() { local rc="$1" local dir="${rc%/*}" + local canonical + canonical="$(_aenv_cc_zsh_block_canonical)" + if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then - if _aenv_cc_rc_well_formed "$rc"; then - return 0 # idempotent: a complete managed block already exists + if ! _aenv_cc_rc_well_formed "$rc"; then + printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + return 0 + fi + local current + current="$(_aenv_cc_zsh_block_current "$rc")" + if [[ "$current" == "$canonical" ]]; then + return 0 # idempotent: a complete, up-to-date managed block already exists fi - printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 + # Stale block: rewrite just that span in place, atomically. + local target tmp + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 + return 0 + } + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { + printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 + return 0 + } + if awk -v block="$canonical" ' + BEGIN { in_block = 0 } + /^# >>> aenv completion >>>$/ { print block; in_block = 1; next } + /^# <<< aenv completion <<<$/ { in_block = 0; next } + in_block { next } + { print } + ' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then + return 0 + fi + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not update stale block in %s\n' "$rc" >&2 return 0 fi + if ! mkdir -p "$dir" 2>/dev/null; then printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 return 0 fi local target tmp last_byte - target="$(_aenv_cc_resolve "$rc")" + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 + return 0 + } tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 return 0 @@ -153,15 +294,7 @@ _aenv_cc_put_zsh_rc() { last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } fi - if ! { - printf '# >>> aenv completion >>>\n' - printf 'if command -v aenv >/dev/null 2>&1; then\n' - printf 'autoload -Uz compinit && compinit\n' - # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash - printf 'eval "$(aenv completion zsh)"\n' - printf 'fi\n' - printf '# <<< aenv completion <<<\n' - } >> "$tmp" 2>/dev/null; then + if ! printf '%s\n' "$canonical" >> "$tmp" 2>/dev/null; then rm -f "$tmp" 2>/dev/null printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 return 0 @@ -172,10 +305,16 @@ _aenv_cc_put_zsh_rc() { } } -# Generate the static zsh completion into a site-functions dir. $2 is the -# just-installed aenv binary (preferred over whatever is on PATH, which may be -# stale or absent). Generation goes through `_aenv_cc_commit`, so a failure or -# empty output never replaces an existing valid completion file. +_aenv_cc_put_zsh_rc() { + _aenv_cc_with_zsh_lock "$1" _aenv_cc_put_zsh_rc_impl "$1" +} + +# Generate the static zsh completion into a site-functions dir, prefixed with +# the aenv ownership marker (as a comment) so uninstall can verify ownership. +# $2 is the just-installed aenv binary (preferred over whatever is on PATH, +# which may be stale or absent). Generation goes through `_aenv_cc_commit`, +# so a failure or empty output never replaces an existing valid completion +# file. # $1 destination _aenv path # $2 aenv binary to invoke (default: aenv from PATH) _aenv_cc_put_zsh_static() { @@ -194,30 +333,74 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 return 0 fi + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 + return 0 + fi local tmp - tmp="$(mktemp "${path}.XXXXXX" 2>/dev/null)" || { + tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 return 0 } - # Require non-empty output: a broken aenv that exits 0 with no bytes must not - # erase a working completion via the atomic rename. - if "${gen[@]}" > "$tmp" 2>/dev/null && [[ -s "$tmp" ]] && _aenv_cc_commit "$tmp" "$path"; then + # Generate the completion body first and require it to be non-empty: a + # broken aenv that exits 0 with no bytes must not erase a working completion + # via the atomic rename. The ownership marker is appended AFTER this check + # (as a trailing comment) so the generated #compdef stays on line 1 — zsh + # only loads the function if #compdef is the first line. + if ! "${gen[@]}" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then + rm -f "$tmp" 2>/dev/null || true + # shellcheck disable=SC2016 # backticks are literal text in a warning + printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 + return 0 + fi + printf '%s\n' "$_AENV_CC_MARKER" >> "$tmp" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null || true + printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 + return 0 + } + if _aenv_cc_commit "$tmp" "$path"; then chmod 0644 "$path" 2>/dev/null || true return 0 fi rm -f "$tmp" 2>/dev/null || true - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 + printf 'warn: aenv completion: could not commit static zsh file %s\n' "$path" >&2 } -# Remove every well-formed aenv marker block from ~/.zshrc. Refuses to touch a -# file with a malformed (partial/nested/reordered/orphan) block. The rewrite is -# staged to a same-directory temp and committed by an atomic rename via -# `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a symlinked rc) -# the link itself are preserved and a failed/partial awk never reaches the live -# file. +# True if $1 is a plain file that starts with the aenv ownership marker — +# i.e. a file this installer created and is safe to remove. A pre-existing, +# hand-written, or package-manager-owned completion file at the same +# conventional path will NOT match, and is left alone. +_aenv_cc_owns() { + [[ -f "$1" ]] || return 1 + # Match the marker anywhere: bash/fish stubs carry it on line 1, while the + # static zsh file carries it as a trailing comment (its #compdef must stay + # on line 1 for zsh to load the function). + grep -qF -- "$_AENV_CC_MARKER" "$1" 2>/dev/null +} + +# Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it +# untouched so a user's own completion file, or one now owned by a package +# manager, is never silently deleted. +_aenv_cc_rm_owned() { + local path="$1" + [[ -e "$path" ]] || return 0 + if _aenv_cc_owns "$path"; then + rm -f "$path" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 + else + printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 + fi +} + +# Remove every well-formed aenv marker block from the zsh rc. Refuses to touch +# a file with a malformed (partial/nested/reordered/orphan) block. The +# rewrite is staged to a same-directory temp and committed by an atomic +# rename via `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a +# symlinked rc) the link itself are preserved, and a failed/partial awk never +# reaches the live file. Locked the same way as install to avoid racing a +# concurrent install/uninstall or hand-edit. # $1 rc file path -_aenv_cc_rm_zsh_rc() { +_aenv_cc_rm_zsh_rc_impl() { local rc="$1" [[ -f "$rc" ]] || return 0 grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 @@ -226,15 +409,14 @@ _aenv_cc_rm_zsh_rc() { return 0 fi local target tmp - target="$(_aenv_cc_resolve "$rc")" - # Temp in the rc's own directory so the rename is atomic (same filesystem). + target="$(_aenv_cc_resolve "$rc")" || { + printf 'warn: aenv completion: %s is a symlink that could not be resolved; leaving it untouched\n' "$rc" >&2 + return 0 + } tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 return 0 } - # awk's exit status is the signal: on success its output (possibly empty if - # the rc held only the managed block) is the correct new content; on failure - # (read/parse error) it is left partial and we never commit it. if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then return 0 fi @@ -242,6 +424,10 @@ _aenv_cc_rm_zsh_rc() { printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 } +_aenv_cc_rm_zsh_rc() { + _aenv_cc_with_zsh_lock "$1" _aenv_cc_rm_zsh_rc_impl "$1" +} + # Install or remove the aenv shell-completion loaders. # # aenv_completion_install install [--prefix=

] [--user] @@ -306,11 +492,12 @@ aenv_completion_install() { _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" fi else - rm -f "$bash_file" "$fish_file" 2>/dev/null || true + _aenv_cc_rm_owned "$bash_file" + _aenv_cc_rm_owned "$fish_file" if [[ "$zsh_kind" == "rc" ]]; then _aenv_cc_rm_zsh_rc "$zsh_file" else - rm -f "$zsh_file" 2>/dev/null || true + _aenv_cc_rm_owned "$zsh_file" fi fi return 0 diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index 278a2163..30072cd1 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -197,9 +197,55 @@ leftovers=( "$sys_prefix/share/zsh/site-functions"/* ) rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share/zsh/site-functions" # --------------------------------------------------------------------------- -# Test 9: unrelated user lines around the managed block survive install+uninstall -# (a regression that truncates the rc while removing a balanced block must fail). +# Test 8b: a zero-exit-but-empty `aenv completion zsh` must NOT replace a valid +# existing _aenv (the installer rejects an empty generated temp before rename). # --------------------------------------------------------------------------- +echo "==> empty aenv completion zsh output leaves the existing _aenv intact" +mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" +printf '#!/usr/bin/env bash\nexit 0\n' > "$sys_prefix/bin/aenv" +chmod +x "$sys_prefix/bin/aenv" +echo '# pre-existing valid zsh completion' > "$sys_prefix/share/zsh/site-functions/_aenv" +cp "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv8b.orig" +HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ + || fail "helper aborted on empty completion output" +cmp -s "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv8b.orig" \ + || fail "empty output replaced a valid _aenv" +rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share/zsh/site-functions" + +# --------------------------------------------------------------------------- +# Test 8c: the static zsh file keeps #compdef on line 1 (the ownership marker +# is appended, not prepended, so zsh still loads the function) and carries the +# ownership marker so a later uninstall can recognize it. +# --------------------------------------------------------------------------- +echo "==> static zsh keeps #compdef first-line and carries the ownership marker" +mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" +# shellcheck disable=SC2016 # ${1:-} is literal text for the stub script, not this shell +printf '#!/usr/bin/env bash\ncase "${1:-}" in completion) echo "#compdef aenv"; echo "echo body";; esac\n' > "$sys_prefix/bin/aenv" +chmod +x "$sys_prefix/bin/aenv" +HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 +[[ "$(head -1 "$sys_prefix/share/zsh/site-functions/_aenv")" == "#compdef aenv" ]] \ + || fail "static zsh #compdef must be the first line" +grep -qF -- "# managed by aenv-installer" "$sys_prefix/share/zsh/site-functions/_aenv" \ + || fail "static zsh must carry the ownership marker" +HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" >/dev/null 2>&1 +assert_absent "$sys_prefix/share/zsh/site-functions/_aenv" +rm -rf "${sys_prefix:?}/bin" + +# --------------------------------------------------------------------------- +# Test 8d: uninstall will NOT delete a completion file the installer did not +# create (no ownership marker) — a hand-maintained or package-manager file at +# the conventional path is left untouched with a warning. +# --------------------------------------------------------------------------- +echo "==> uninstall leaves a non-aenv-owned completion file untouched" +mkdir -p "$sys_prefix/share/bash-completion/completions" "$sys_prefix/share/fish/vendor_completions.d" +printf '# my hand-written aenv completion\n' > "$sys_prefix/share/bash-completion/completions/aenv" +printf '# my fish completion\n' > "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" +HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" 2>/dev/null +assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'my hand-written' +assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'my fish completion' +rm -rf "${sys_prefix:?}/share" + + echo "==> unrelated rc content survives install and uninstall" home_surround="$tmp_root/home-surround" mkdir -p "$home_surround" From fc358d3fec644a4dd947700263a3df88a6ae6f68 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Sun, 2 Aug 2026 22:36:52 +0200 Subject: [PATCH 20/23] =?UTF-8?q?fix(cli):=20round=205=20=E2=80=94=20compi?= =?UTF-8?q?le=20fix,=20portable=20resolve/chown,=20install-side=20owns=20g?= =?UTF-8?q?uard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the fifth review pass. Real bugs in the ownership-marker/locking rewrite plus a compile break. crates/aenv: fix a compile break from the FailingWriter suggestion — the struct has named fields (kind, fail_on_flush) but the two tests still constructed it tuple-style; the crate could not compile. Use named-field construction. Canonical block (inlined into shell-completion.sh / install-cli.sh / install.sh): - _aenv_cc_resolve: replace the one-hop BSD fallback (which could return an intermediate symlink and let mv replace it) with a bounded, cycle-detecting readlink loop. Multi-hop chains resolve portably without readlink -f; broken symlinks are rejected; a plain non-existent path is returned as-is so first-install is not blocked. - _aenv_cc_commit: replace GNU-only chown --reference with portable uid:gid extraction (stat -c / stat -f) + chown; refuse to commit when ownership preservation was required but failed. - _aenv_cc_with_zsh_lock: lock the rc's own read-only fd instead of a sidecar lock file — removes the symlink-truncation vector for privileged runs, the set -e abort on an unwritable lock path, and the leftover-artifact file. - _aenv_cc_put / _aenv_cc_put_zsh_static: install-side ownership guard (symmetric with uninstall) — do not overwrite an existing unmanaged file; ensure a separating newline before the appended marker when generated output lacks a trailing newline. - _aenv_cc_rm_owned: resolve the path before the ownership check and removal so a cycle through a symlinked completion removes the managed target, not the link. Tests: seed a managed _aenv before swapping to failing/empty stubs (so the new install-side guard permits the re-install attempt); capture baselines before and clear the invocation sentinel between attempts; add install-over-unowned. --- crates/aenv/src/commands/completion.rs | 4 +- scripts/install-cli.sh | 90 ++++++++++++++++-------- scripts/install.sh | 90 ++++++++++++++++-------- scripts/shell-completion.sh | 90 ++++++++++++++++-------- scripts/tests/verify-shell-completion.sh | 46 ++++++++---- 5 files changed, 221 insertions(+), 99 deletions(-) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index 2adb05f8..88a1c38c 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -183,14 +183,14 @@ mod tests { fn broken_pipe_is_treated_as_success() { // `aenv completion bash | head` closes the pipe early; that must exit // cleanly rather than error or panic. - let mut out = FailingWriter(std::io::ErrorKind::BrokenPipe); + let mut out = FailingWriter { kind: std::io::ErrorKind::BrokenPipe, fail_on_flush: false }; write_completion(Shell::Bash, &mut out) .expect("BrokenPipe during completion output should not error"); } #[test] fn other_io_error_propagates() { - let mut out = FailingWriter(std::io::ErrorKind::Other); + let mut out = FailingWriter { kind: std::io::ErrorKind::Other, fail_on_flush: false }; let err = write_completion(Shell::Bash, &mut out) .expect_err("non-BrokenPipe I/O errors should propagate"); assert!( diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index d54626da..e86aac1e 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -106,24 +106,26 @@ _aenv_cc_mode_octal() { # would replace the symlink itself, silently breaking the "preserve the # link" guarantee this whole module advertises. _aenv_cc_resolve() { - if [[ -L "$1" ]]; then - local resolved - if resolved="$(readlink -f "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then - printf '%s' "$resolved" - return 0 - fi - # Portable one-hop fallback for platforms without GNU `readlink -f` - # (e.g. some BSD/macOS readlink builds). Only handles a single-level - # symlink, which covers the common case; anything more exotic - # (relative multi-hop chains) is treated as unresolvable. - if resolved="$(readlink "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then - [[ "$resolved" = /* ]] || resolved="${1%/*}/$resolved" - printf '%s' "$resolved" - return 0 + local p="$1" link hops=0 + # Walk the symlink chain portably with a bound (cycle detection), so + # multi-hop chains resolve on BSD/macOS (no `readlink -f`) as well as GNU. + # Returns failure on an unreadable or broken symlink, so callers never mv + # over an intermediate link. A plain (non-symlink) path is returned as-is + # even when it does not yet exist, so first-install (which creates the file) + # is not blocked. + while [[ -L "$p" ]]; do + if ! link=$(readlink "$p" 2>/dev/null) || [[ -z "$link" ]]; then + return 1 fi - return 1 - fi - printf '%s' "$1" + [[ "$link" = /* ]] || link="${p%/*}/$link" + p="$link" + hops=$((hops + 1)) + [[ "$hops" -lt 40 ]] || return 1 + done + # If we followed at least one hop and landed on a non-existent path, the + # link chain is broken — refuse rather than mv into nothing. + [[ "$hops" -gt 0 && ! -e "$p" ]] && return 1 + printf '%s' "$p" return 0 } @@ -144,8 +146,15 @@ _aenv_cc_commit() { return 1 fi chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + # Preserve ownership when running as root. `chown --reference` is GNU-only, + # so read uid:gid portably (GNU `stat -c`, BSD/macOS `stat -f`) and chown + # explicitly; do NOT silently commit a root-owned temp over a user file. if [[ $EUID -eq 0 && -e "$target" ]]; then - chown --reference="$target" "$tmp" 2>/dev/null || true + local ids + ids=$(stat -c '%u:%g' "$target" 2>/dev/null || stat -f '%u:%g' "$target" 2>/dev/null || true) + if [[ -n "$ids" ]] && ! chown "$ids" "$tmp" 2>/dev/null; then + return 1 + fi fi mv -f "$tmp" "$target" 2>/dev/null } @@ -173,6 +182,12 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 return 0 fi + # Do not overwrite a pre-existing file we did not create (a hand-written or + # package-manager completion). Symmetric with _aenv_cc_rm_owned. + if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then + printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 + return 0 + fi local tmp tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 @@ -239,14 +254,17 @@ _aenv_cc_zsh_block_current() { # $2... function name + args to run inside the lock _aenv_cc_with_zsh_lock() { local rc="$1"; shift - if command -v flock >/dev/null 2>&1; then + # Lock the rc's OWN fd (read-only open: no truncation, and no sidecar lock + # file in the user's directory that a privileged run could be tricked into + # following as a symlink). Best-effort: fall back to unlocked where flock is + # missing or the rc does not yet exist (first install). + if command -v flock >/dev/null 2>&1 && [[ -e "$rc" ]]; then ( - flock -w 10 200 || { - printf 'warn: aenv completion: could not lock %s (timed out); skipping\n' "$rc" >&2 - exit 0 - } + exec 200<"$rc" 2>/dev/null || { "$@"; exit; } + flock -w 10 200 2>/dev/null || \ + printf 'warn: aenv completion: could not lock %s (timed out); proceeding unlocked\n' "$rc" >&2 "$@" - ) 200>"${rc}.aenv-lock" 2>&2 + ) else "$@" fi @@ -371,6 +389,10 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 return 0 fi + if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then + printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 + return 0 + fi local tmp tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 @@ -387,7 +409,12 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 return 0 fi - printf '%s\n' "$_AENV_CC_MARKER" >> "$tmp" 2>/dev/null || { + # Ensure a newline separates the completion body from the marker comment so + # a generator that omits a trailing newline does not fuse the marker onto + # the last shell statement. + local last_byte + last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + { [[ "$last_byte" == "0a" ]] || printf '\n'; printf '%s\n' "$_AENV_CC_MARKER"; } >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 return 0 @@ -414,12 +441,19 @@ _aenv_cc_owns() { # Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it # untouched so a user's own completion file, or one now owned by a package -# manager, is never silently deleted. +# manager, is never silently deleted. Resolves symlinks before both the +# ownership check and the removal so an install/uninstall cycle through a +# symlinked completion path removes the managed TARGET we wrote, not the link. _aenv_cc_rm_owned() { local path="$1" [[ -e "$path" ]] || return 0 - if _aenv_cc_owns "$path"; then - rm -f "$path" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is an unresolvable symlink; leaving it untouched\n' "$path" >&2 + return 0 + fi + if _aenv_cc_owns "$target"; then + rm -f "$target" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 else printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 fi diff --git a/scripts/install.sh b/scripts/install.sh index 7f7f5363..5b63e6a2 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -102,24 +102,26 @@ _aenv_cc_mode_octal() { # would replace the symlink itself, silently breaking the "preserve the # link" guarantee this whole module advertises. _aenv_cc_resolve() { - if [[ -L "$1" ]]; then - local resolved - if resolved="$(readlink -f "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then - printf '%s' "$resolved" - return 0 - fi - # Portable one-hop fallback for platforms without GNU `readlink -f` - # (e.g. some BSD/macOS readlink builds). Only handles a single-level - # symlink, which covers the common case; anything more exotic - # (relative multi-hop chains) is treated as unresolvable. - if resolved="$(readlink "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then - [[ "$resolved" = /* ]] || resolved="${1%/*}/$resolved" - printf '%s' "$resolved" - return 0 + local p="$1" link hops=0 + # Walk the symlink chain portably with a bound (cycle detection), so + # multi-hop chains resolve on BSD/macOS (no `readlink -f`) as well as GNU. + # Returns failure on an unreadable or broken symlink, so callers never mv + # over an intermediate link. A plain (non-symlink) path is returned as-is + # even when it does not yet exist, so first-install (which creates the file) + # is not blocked. + while [[ -L "$p" ]]; do + if ! link=$(readlink "$p" 2>/dev/null) || [[ -z "$link" ]]; then + return 1 fi - return 1 - fi - printf '%s' "$1" + [[ "$link" = /* ]] || link="${p%/*}/$link" + p="$link" + hops=$((hops + 1)) + [[ "$hops" -lt 40 ]] || return 1 + done + # If we followed at least one hop and landed on a non-existent path, the + # link chain is broken — refuse rather than mv into nothing. + [[ "$hops" -gt 0 && ! -e "$p" ]] && return 1 + printf '%s' "$p" return 0 } @@ -140,8 +142,15 @@ _aenv_cc_commit() { return 1 fi chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + # Preserve ownership when running as root. `chown --reference` is GNU-only, + # so read uid:gid portably (GNU `stat -c`, BSD/macOS `stat -f`) and chown + # explicitly; do NOT silently commit a root-owned temp over a user file. if [[ $EUID -eq 0 && -e "$target" ]]; then - chown --reference="$target" "$tmp" 2>/dev/null || true + local ids + ids=$(stat -c '%u:%g' "$target" 2>/dev/null || stat -f '%u:%g' "$target" 2>/dev/null || true) + if [[ -n "$ids" ]] && ! chown "$ids" "$tmp" 2>/dev/null; then + return 1 + fi fi mv -f "$tmp" "$target" 2>/dev/null } @@ -169,6 +178,12 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 return 0 fi + # Do not overwrite a pre-existing file we did not create (a hand-written or + # package-manager completion). Symmetric with _aenv_cc_rm_owned. + if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then + printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 + return 0 + fi local tmp tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 @@ -235,14 +250,17 @@ _aenv_cc_zsh_block_current() { # $2... function name + args to run inside the lock _aenv_cc_with_zsh_lock() { local rc="$1"; shift - if command -v flock >/dev/null 2>&1; then + # Lock the rc's OWN fd (read-only open: no truncation, and no sidecar lock + # file in the user's directory that a privileged run could be tricked into + # following as a symlink). Best-effort: fall back to unlocked where flock is + # missing or the rc does not yet exist (first install). + if command -v flock >/dev/null 2>&1 && [[ -e "$rc" ]]; then ( - flock -w 10 200 || { - printf 'warn: aenv completion: could not lock %s (timed out); skipping\n' "$rc" >&2 - exit 0 - } + exec 200<"$rc" 2>/dev/null || { "$@"; exit; } + flock -w 10 200 2>/dev/null || \ + printf 'warn: aenv completion: could not lock %s (timed out); proceeding unlocked\n' "$rc" >&2 "$@" - ) 200>"${rc}.aenv-lock" 2>&2 + ) else "$@" fi @@ -367,6 +385,10 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 return 0 fi + if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then + printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 + return 0 + fi local tmp tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 @@ -383,7 +405,12 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 return 0 fi - printf '%s\n' "$_AENV_CC_MARKER" >> "$tmp" 2>/dev/null || { + # Ensure a newline separates the completion body from the marker comment so + # a generator that omits a trailing newline does not fuse the marker onto + # the last shell statement. + local last_byte + last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + { [[ "$last_byte" == "0a" ]] || printf '\n'; printf '%s\n' "$_AENV_CC_MARKER"; } >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 return 0 @@ -410,12 +437,19 @@ _aenv_cc_owns() { # Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it # untouched so a user's own completion file, or one now owned by a package -# manager, is never silently deleted. +# manager, is never silently deleted. Resolves symlinks before both the +# ownership check and the removal so an install/uninstall cycle through a +# symlinked completion path removes the managed TARGET we wrote, not the link. _aenv_cc_rm_owned() { local path="$1" [[ -e "$path" ]] || return 0 - if _aenv_cc_owns "$path"; then - rm -f "$path" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is an unresolvable symlink; leaving it untouched\n' "$path" >&2 + return 0 + fi + if _aenv_cc_owns "$target"; then + rm -f "$target" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 else printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 fi diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh index 873d1cf7..625bac56 100755 --- a/scripts/shell-completion.sh +++ b/scripts/shell-completion.sh @@ -73,24 +73,26 @@ _aenv_cc_mode_octal() { # would replace the symlink itself, silently breaking the "preserve the # link" guarantee this whole module advertises. _aenv_cc_resolve() { - if [[ -L "$1" ]]; then - local resolved - if resolved="$(readlink -f "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then - printf '%s' "$resolved" - return 0 - fi - # Portable one-hop fallback for platforms without GNU `readlink -f` - # (e.g. some BSD/macOS readlink builds). Only handles a single-level - # symlink, which covers the common case; anything more exotic - # (relative multi-hop chains) is treated as unresolvable. - if resolved="$(readlink "$1" 2>/dev/null)" && [[ -n "$resolved" ]]; then - [[ "$resolved" = /* ]] || resolved="${1%/*}/$resolved" - printf '%s' "$resolved" - return 0 + local p="$1" link hops=0 + # Walk the symlink chain portably with a bound (cycle detection), so + # multi-hop chains resolve on BSD/macOS (no `readlink -f`) as well as GNU. + # Returns failure on an unreadable or broken symlink, so callers never mv + # over an intermediate link. A plain (non-symlink) path is returned as-is + # even when it does not yet exist, so first-install (which creates the file) + # is not blocked. + while [[ -L "$p" ]]; do + if ! link=$(readlink "$p" 2>/dev/null) || [[ -z "$link" ]]; then + return 1 fi - return 1 - fi - printf '%s' "$1" + [[ "$link" = /* ]] || link="${p%/*}/$link" + p="$link" + hops=$((hops + 1)) + [[ "$hops" -lt 40 ]] || return 1 + done + # If we followed at least one hop and landed on a non-existent path, the + # link chain is broken — refuse rather than mv into nothing. + [[ "$hops" -gt 0 && ! -e "$p" ]] && return 1 + printf '%s' "$p" return 0 } @@ -111,8 +113,15 @@ _aenv_cc_commit() { return 1 fi chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true + # Preserve ownership when running as root. `chown --reference` is GNU-only, + # so read uid:gid portably (GNU `stat -c`, BSD/macOS `stat -f`) and chown + # explicitly; do NOT silently commit a root-owned temp over a user file. if [[ $EUID -eq 0 && -e "$target" ]]; then - chown --reference="$target" "$tmp" 2>/dev/null || true + local ids + ids=$(stat -c '%u:%g' "$target" 2>/dev/null || stat -f '%u:%g' "$target" 2>/dev/null || true) + if [[ -n "$ids" ]] && ! chown "$ids" "$tmp" 2>/dev/null; then + return 1 + fi fi mv -f "$tmp" "$target" 2>/dev/null } @@ -140,6 +149,12 @@ _aenv_cc_put() { printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 return 0 fi + # Do not overwrite a pre-existing file we did not create (a hand-written or + # package-manager completion). Symmetric with _aenv_cc_rm_owned. + if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then + printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 + return 0 + fi local tmp tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 @@ -206,14 +221,17 @@ _aenv_cc_zsh_block_current() { # $2... function name + args to run inside the lock _aenv_cc_with_zsh_lock() { local rc="$1"; shift - if command -v flock >/dev/null 2>&1; then + # Lock the rc's OWN fd (read-only open: no truncation, and no sidecar lock + # file in the user's directory that a privileged run could be tricked into + # following as a symlink). Best-effort: fall back to unlocked where flock is + # missing or the rc does not yet exist (first install). + if command -v flock >/dev/null 2>&1 && [[ -e "$rc" ]]; then ( - flock -w 10 200 || { - printf 'warn: aenv completion: could not lock %s (timed out); skipping\n' "$rc" >&2 - exit 0 - } + exec 200<"$rc" 2>/dev/null || { "$@"; exit; } + flock -w 10 200 2>/dev/null || \ + printf 'warn: aenv completion: could not lock %s (timed out); proceeding unlocked\n' "$rc" >&2 "$@" - ) 200>"${rc}.aenv-lock" 2>&2 + ) else "$@" fi @@ -338,6 +356,10 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 return 0 fi + if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then + printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 + return 0 + fi local tmp tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 @@ -354,7 +376,12 @@ _aenv_cc_put_zsh_static() { printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 return 0 fi - printf '%s\n' "$_AENV_CC_MARKER" >> "$tmp" 2>/dev/null || { + # Ensure a newline separates the completion body from the marker comment so + # a generator that omits a trailing newline does not fuse the marker onto + # the last shell statement. + local last_byte + last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" + { [[ "$last_byte" == "0a" ]] || printf '\n'; printf '%s\n' "$_AENV_CC_MARKER"; } >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 return 0 @@ -381,12 +408,19 @@ _aenv_cc_owns() { # Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it # untouched so a user's own completion file, or one now owned by a package -# manager, is never silently deleted. +# manager, is never silently deleted. Resolves symlinks before both the +# ownership check and the removal so an install/uninstall cycle through a +# symlinked completion path removes the managed TARGET we wrote, not the link. _aenv_cc_rm_owned() { local path="$1" [[ -e "$path" ]] || return 0 - if _aenv_cc_owns "$path"; then - rm -f "$path" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 + local target + if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then + printf 'warn: aenv completion: %s is an unresolvable symlink; leaving it untouched\n' "$path" >&2 + return 0 + fi + if _aenv_cc_owns "$target"; then + rm -f "$target" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 else printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 fi diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh index 30072cd1..c3835673 100755 --- a/scripts/tests/verify-shell-completion.sh +++ b/scripts/tests/verify-shell-completion.sh @@ -176,41 +176,61 @@ assert_absent "$sys_prefix/share/bash-completion/completions/aenv" # Test 8: a failing `aenv completion zsh` must not truncate an existing valid # static file (atomic temp+rename), and must not leave temp files behind. # --------------------------------------------------------------------------- -echo "==> failing aenv completion zsh leaves the existing _aenv intact" +echo "==> failing aenv completion zsh leaves the existing managed _aenv intact" mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" invoked="$tmp_root/failing-aenv-invoked" -printf '#!/usr/bin/env bash\nprintf x > "%s"\nexit 1\n' "$invoked" > "$sys_prefix/bin/aenv" +# Seed a managed _aenv with a working aenv first, so the install-side ownership +# guard permits a later re-install attempt; then swap to a failing aenv. +# shellcheck disable=SC2016 # ${1:-} is literal stub text +printf '#!/usr/bin/env bash\ncase "${1:-}" in completion) echo "#compdef aenv"; echo "echo body";; esac\n' > "$sys_prefix/bin/aenv" chmod +x "$sys_prefix/bin/aenv" -echo '# pre-existing valid zsh completion' > "$sys_prefix/share/zsh/site-functions/_aenv" -HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ - || fail "helper aborted when aenv completion zsh exits nonzero" -[[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked (regression: fell back to PATH/no-aenv branch)" +HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 +assert_contains "$sys_prefix/share/zsh/site-functions/_aenv" '#compdef aenv' cp "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv.orig" +printf '#!/usr/bin/env bash\nprintf x > "%s"\nexit 1\n' "$invoked" > "$sys_prefix/bin/aenv" +chmod +x "$sys_prefix/bin/aenv" +rm -f "$invoked" HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ || fail "helper aborted when aenv completion zsh exits nonzero" -[[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked (regression: fell back to PATH/no-aenv branch)" +[[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked on re-install" cmp -s "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv.orig" \ || fail "failed generation modified the existing _aenv" -# Exactly one file in the site-functions dir (no leftover .XXXXXX temp). leftovers=( "$sys_prefix/share/zsh/site-functions"/* ) [[ "${#leftovers[@]}" -eq 1 ]] || fail "expected no temp leftovers, found: ${leftovers[*]}" -rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share/zsh/site-functions" +rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share" + +# --------------------------------------------------------------------------- +# Test 8e: install must NOT overwrite an existing completion file the installer +# did not create (install-side ownership guard, symmetric with uninstall). +# --------------------------------------------------------------------------- +echo "==> install leaves an existing non-aenv-owned completion file untouched" +mkdir -p "$sys_prefix/share/bash-completion/completions" "$sys_prefix/share/fish/vendor_completions.d" +printf '# my hand-written bash completion\n' > "$sys_prefix/share/bash-completion/completions/aenv" +printf '# my fish completion\n' > "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" +HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null +assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'my hand-written' +assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'my fish completion' +rm -rf "${sys_prefix:?}/share" # --------------------------------------------------------------------------- # Test 8b: a zero-exit-but-empty `aenv completion zsh` must NOT replace a valid # existing _aenv (the installer rejects an empty generated temp before rename). # --------------------------------------------------------------------------- -echo "==> empty aenv completion zsh output leaves the existing _aenv intact" +echo "==> empty aenv completion zsh output leaves the existing managed _aenv intact" mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" -printf '#!/usr/bin/env bash\nexit 0\n' > "$sys_prefix/bin/aenv" +# Seed managed _aenv (working aenv), then swap to an aenv that exits 0 with no bytes. +# shellcheck disable=SC2016 # ${1:-} is literal stub text +printf '#!/usr/bin/env bash\ncase "${1:-}" in completion) echo "#compdef aenv"; echo "echo body";; esac\n' > "$sys_prefix/bin/aenv" chmod +x "$sys_prefix/bin/aenv" -echo '# pre-existing valid zsh completion' > "$sys_prefix/share/zsh/site-functions/_aenv" +HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 cp "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv8b.orig" +printf '#!/usr/bin/env bash\nexit 0\n' > "$sys_prefix/bin/aenv" +chmod +x "$sys_prefix/bin/aenv" HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ || fail "helper aborted on empty completion output" cmp -s "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv8b.orig" \ || fail "empty output replaced a valid _aenv" -rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share/zsh/site-functions" +rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share" # --------------------------------------------------------------------------- # Test 8c: the static zsh file keeps #compdef on line 1 (the ownership marker From 5a03bb8b71db8ec86c3bd02330c308090b852a9c Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Mon, 3 Aug 2026 21:36:04 +0200 Subject: [PATCH 21/23] refactor(cli): split automatic completion installation --- .github/workflows/ci.yml | 32 -- Makefile | 28 -- crates/aenv/src/commands/completion.rs | 16 +- crates/aenv/src/main.rs | 6 +- docs/src/getting-started/aenv-cli.md | 25 +- scripts/check-completion-sync.sh | 80 ---- scripts/install-cli.sh | 512 --------------------- scripts/install.sh | 509 --------------------- scripts/shell-completion.sh | 556 ----------------------- scripts/tests/verify-shell-completion.sh | 294 ------------ 10 files changed, 18 insertions(+), 2040 deletions(-) delete mode 100755 scripts/check-completion-sync.sh delete mode 100755 scripts/shell-completion.sh delete mode 100755 scripts/tests/verify-shell-completion.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58b6eb3c..691c6d37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,14 +10,10 @@ on: - "**/Cargo.lock" - "rust-toolchain.toml" - "scripts/install.sh" - - "scripts/install-cli.sh" - - "scripts/shell-completion.sh" - - "scripts/check-completion-sync.sh" - "scripts/run-with-capabilities.sh" - "scripts/tests/setup-ublk-access.sh" - "scripts/tests/verify-capability-runner.sh" - "scripts/tests/verify-install-service.sh" - - "scripts/tests/verify-shell-completion.sh" - ".github/actions/**" - ".github/workflows/ci.yml" push: @@ -28,14 +24,10 @@ on: - "**/Cargo.lock" - "rust-toolchain.toml" - "scripts/install.sh" - - "scripts/install-cli.sh" - - "scripts/shell-completion.sh" - - "scripts/check-completion-sync.sh" - "scripts/run-with-capabilities.sh" - "scripts/tests/setup-ublk-access.sh" - "scripts/tests/verify-capability-runner.sh" - "scripts/tests/verify-install-service.sh" - - "scripts/tests/verify-shell-completion.sh" - ".github/actions/**" - ".github/workflows/ci.yml" @@ -71,27 +63,3 @@ jobs: run: sudo scripts/tests/setup-ublk-access.sh "$(id -un)" "$(id -gn)" - name: Unit tests run: make test-unit PROFILE=debug - - shell-scripts: - # Static + functional checks for the shell-completion installer helpers and - # the standalone installers. No Rust toolchain needed. - runs-on: ubuntu-22.04 - timeout-minutes: 15 - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - name: Install shellcheck - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck - - name: Shellcheck - run: | - shellcheck -s bash \ - scripts/shell-completion.sh \ - scripts/check-completion-sync.sh \ - scripts/install-cli.sh \ - scripts/install.sh \ - scripts/tests/verify-shell-completion.sh - - name: Completion-loader drift check - run: bash scripts/check-completion-sync.sh - - name: Completion-loader functional test - run: bash scripts/tests/verify-shell-completion.sh diff --git a/Makefile b/Makefile index 78bc9463..116b30c0 100644 --- a/Makefile +++ b/Makefile @@ -28,11 +28,6 @@ AENV_INSTALL_DIR := $(AENV_INSTALL_PREFIX)/bin # /usr/local; override to empty (AENV_INSTALL_SUDO=) for a user-local prefix. AENV_INSTALL_SUDO ?= sudo -# Set AENV_INSTALL_COMPLETION=0 to skip shell-completion loader setup on -# install/uninstall. The loaders regenerate completion code from the installed -# aenv at shell start, so they never go stale across upgrades. -AENV_INSTALL_COMPLETION ?= 1 - # Script entrypoints TEST_SCRIPTS_DIR := ./scripts/tests @@ -56,7 +51,6 @@ TARGET_PROFILE_DIR = $${CARGO_TARGET_DIR:-$$(pwd)/target}/$(PROFILE) build-snapshot-image \ build-aenv build-aenv-release install-aenv uninstall-aenv \ build-ublk install-ublk \ - check-shell-completion \ fmt clippy \ mutants coverage \ test test-unit test-integration prepare-agent-test-state test-agent test-agent-integration test-envd test-ublk \ @@ -98,33 +92,11 @@ install-aenv: build-aenv-release $(AENV_INSTALL_SUDO) install -d "$(AENV_INSTALL_DIR)" $(AENV_INSTALL_SUDO) install -m 0755 "$${CARGO_TARGET_DIR:-$$(pwd)/target}/release/aenv" "$(AENV_INSTALL_DIR)/aenv" @echo "Installed aenv to $(AENV_INSTALL_DIR)/aenv" -ifeq ($(AENV_INSTALL_COMPLETION),1) - $(AENV_INSTALL_SUDO) ./scripts/shell-completion.sh install --prefix="$(AENV_INSTALL_PREFIX)" -endif uninstall-aenv: -ifeq ($(AENV_INSTALL_COMPLETION),1) - $(AENV_INSTALL_SUDO) ./scripts/shell-completion.sh uninstall --prefix="$(AENV_INSTALL_PREFIX)" -endif $(AENV_INSTALL_SUDO) rm -f "$(AENV_INSTALL_DIR)/aenv" @echo "Removed $(AENV_INSTALL_DIR)/aenv" -# Verify the shell-completion installer helper and its inlined copies stay in -# sync. Run from CI and before relying on the installers. -check-shell-completion: - bash scripts/check-completion-sync.sh - bash scripts/tests/verify-shell-completion.sh - @if command -v shellcheck >/dev/null 2>&1; then \ - shellcheck -s bash \ - scripts/shell-completion.sh \ - scripts/check-completion-sync.sh \ - scripts/install-cli.sh \ - scripts/install.sh \ - scripts/tests/verify-shell-completion.sh; \ - else \ - echo "shellcheck not installed; skipping shellcheck"; \ - fi - fmt: $(CARGO) fmt --all -- --check diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index 88a1c38c..2278c89e 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -8,8 +8,6 @@ use std::io::Write; /// Shell to generate completion for. /// -/// Limited to bash/zsh/fish per issue #37; elvish and powershell are explicit -/// non-goals. #[derive(Copy, Clone, Debug, ValueEnum)] pub enum Shell { Bash, @@ -34,9 +32,7 @@ pub struct Args { pub shell: Shell, } -/// Generate a static completion script for the requested shell and write it to -/// stdout. The command tree is rebuilt from the live `Cli` derive spec via -/// `crate::Cli::command()` so completion can never drift from the real CLI. +/// Generate a completion script for the requested shell and write it to stdout. pub fn run(args: Args) -> Result<()> { write_completion(args.shell, &mut std::io::stdout().lock()) } @@ -183,14 +179,20 @@ mod tests { fn broken_pipe_is_treated_as_success() { // `aenv completion bash | head` closes the pipe early; that must exit // cleanly rather than error or panic. - let mut out = FailingWriter { kind: std::io::ErrorKind::BrokenPipe, fail_on_flush: false }; + let mut out = FailingWriter { + kind: std::io::ErrorKind::BrokenPipe, + fail_on_flush: false, + }; write_completion(Shell::Bash, &mut out) .expect("BrokenPipe during completion output should not error"); } #[test] fn other_io_error_propagates() { - let mut out = FailingWriter { kind: std::io::ErrorKind::Other, fail_on_flush: false }; + let mut out = FailingWriter { + kind: std::io::ErrorKind::Other, + fail_on_flush: false, + }; let err = write_completion(Shell::Bash, &mut out) .expect_err("non-BrokenPipe I/O errors should propagate"); assert!( diff --git a/crates/aenv/src/main.rs b/crates/aenv/src/main.rs index 38c1cfef..beb38218 100644 --- a/crates/aenv/src/main.rs +++ b/crates/aenv/src/main.rs @@ -33,6 +33,8 @@ enum Cmd { Upload(commands::upload::Args), /// Download a file from a sandbox Download(commands::download::Args), + /// Generate shell completion scripts + Completion(commands::completion::Args), /// Attach an interactive shell to a running sandbox #[command(visible_alias = "cn")] Connect(commands::connect::Args), @@ -54,8 +56,6 @@ enum Cmd { /// Template operations #[command(visible_alias = "templates")] Template(commands::template::Args), - /// Generate shell completion scripts - Completion(commands::completion::Args), } fn main() -> Result<()> { @@ -68,6 +68,7 @@ fn main() -> Result<()> { Cmd::Exec(a) => commands::exec::run(a), Cmd::Upload(a) => commands::upload::run(a), Cmd::Download(a) => commands::download::run(a), + Cmd::Completion(a) => commands::completion::run(a), Cmd::Connect(a) => commands::connect::run(a), Cmd::Pause(a) => commands::pause::run(a), Cmd::Resume(a) => commands::resume::run(a), @@ -76,6 +77,5 @@ fn main() -> Result<()> { Cmd::Timeout(a) => commands::timeout::run(a), Cmd::Snapshot(a) => commands::snapshot::run(a), Cmd::Template(a) => commands::template::run(a), - Cmd::Completion(a) => commands::completion::run(a), } } diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 8b52b6c9..76ba795a 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -279,19 +279,7 @@ To delete a snapshot, use `aenv template delete ` or `aenv template ## Shell completion -`aenv completion ` prints a shell-completion script to stdout. The script is rebuilt from the live CLI definition on every run, so it always matches the installed `aenv` — top-level commands, nested subcommands (e.g. `aenv snapshot create`), visible aliases (`cn`, `ls`, `rm`, `snap`, `templates`), flags, the `--output table|json` enum, and local path arguments such as the Dockerfile passed to `aenv build`. - -Three shells are supported; elvish and powershell are explicit non-goals. - -### Installed automatically - -The installers set up **regenerating** completion loaders for you, so the completion is always generated from the currently installed `aenv` and never goes stale across upgrades: - -- `make install-aenv` / `make uninstall-aenv` — installs/removes the loaders (set `AENV_INSTALL_COMPLETION=0` to skip). User-local installs (`AENV_INSTALL_PREFIX=~/.local`) write per-user loaders and an `~/.zshrc` snippet; system installs write under `/share`. -- `scripts/install-cli.sh` — installs loaders into the matching user-local or system directories based on the install location. -- `scripts/install.sh` (full installer) — installs system-wide loaders under `/usr/local/share`. - -After install, open a new shell (or re-source your rc) and the loaders take effect. If you installed with one of the methods above, you can skip the manual steps below — they remain as a fallback for users who skipped the installer or want to customize the location. +`aenv completion ` prints a shell-completion script for the `aenv` CLI to stdout. ### Generate a script @@ -309,11 +297,12 @@ aenv completion zsh > ~/.local/share/zsh/site-functions/_aenv aenv completion fish > ~/.config/fish/completions/aenv.fish ``` -bash and fish auto-load from these directories (bash sources files named after the command from `~/.local/share/bash-completion/completions/`; fish sources `~/.config/fish/completions/`). zsh autoloads `_cmdname` functions from directories on `fpath` — `~/.local/share/zsh/site-functions` is not on `fpath` by default, so if completion does not load, add it before `compinit` in your `~/.zshrc`: +bash and fish auto-load completions from those directories. zsh autoloads `_cmdname` functions from directories on `fpath`; `~/.local/share/zsh/site-functions` is not on `fpath` by default, so if completion does not load, add the directory to `fpath` before `compinit`: ```bash -fpath+=(~/.local/share/zsh/site-functions) -autoload -Uz compinit && compinit +fpath=(~/.local/share/zsh/site-functions $fpath) +autoload -Uz compinit +compinit ``` ### Activate it @@ -326,9 +315,7 @@ eval "$(aenv completion zsh)" # zsh aenv completion fish | source # fish ``` -To make completion persistent, add the matching line to your shell's rc file (`~/.bashrc` or `~/.bash_profile`, `~/.zshrc`, `~/.config/fish/config.fish`) and restart the shell or re-source the file. - -Once loaded, completion covers the static CLI surface: +Once loaded, completion covers the CLI surface: ```bash aenv # top-level commands diff --git a/scripts/check-completion-sync.sh b/scripts/check-completion-sync.sh deleted file mode 100755 index 4801c708..00000000 --- a/scripts/check-completion-sync.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# Verify that the inlined `aenv_completion_install` blocks in the standalone -# installers stay in sync with the canonical copy in scripts/shell-completion.sh. -# Run from CI / `make check-shell-completion`. -# -# The canonical helper is a single source of truth; install-cli.sh and -# install.sh cannot source it (they are curl|bash'd as standalone scripts), so -# they inline a verbatim copy bracketed by the marker comments: -# -# # BEGIN aenv_completion_install -# ... -# # END aenv_completion_install -# -# This script extracts that block from each file into a byte-preserving temp -# file (so trailing-newline differences are not silently normalized), validates -# each file has exactly one well-ordered BEGIN..END pair, and fails on any -# divergence. "In sync" means byte-identical block content across the files. -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -canonical="$repo_root/scripts/shell-completion.sh" -cli="$repo_root/scripts/install-cli.sh" -full="$repo_root/scripts/install.sh" - -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT - -# extract -# Copies the BEGIN..END block (inclusive) to . Fails (exits 1) if the -# markers are absent, duplicated, or reversed. -extract() { - local input="$1" out="$2" - local begins ends first_begin last_end - begins=$(grep -c '^# BEGIN aenv_completion_install$' "$input" || true) - ends=$(grep -c '^# END aenv_completion_install$' "$input" || true) - begins=${begins:-0}; ends=${ends:-0} - [[ "$begins" =~ ^[0-9]+$ ]] || begins=0 - [[ "$ends" =~ ^[0-9]+$ ]] || ends=0 - if [[ "$begins" -ne 1 || "$ends" -ne 1 ]]; then - echo "error: expected exactly one BEGIN and one END marker in $input (found $begins BEGIN, $ends END)" >&2 - return 1 - fi - first_begin=$(grep -n '^# BEGIN aenv_completion_install$' "$input" | cut -d: -f1) - last_end=$(grep -n '^# END aenv_completion_install$' "$input" | cut -d: -f1) - if [[ "$first_begin" -gt "$last_end" ]]; then - echo "error: END marker precedes BEGIN marker in $input" >&2 - return 1 - fi - sed -n "${first_begin},${last_end}p" "$input" > "$out" -} - -if ! extract "$canonical" "$tmp_dir/canonical"; then - exit 1 -fi - -rc=0 -for f in "$cli" "$full"; do - if ! extract "$f" "$tmp_dir/cand"; then - rc=1 - continue - fi - if ! cmp -s "$tmp_dir/canonical" "$tmp_dir/cand"; then - echo "error: aenv_completion_install block in $f differs from $canonical" >&2 - diff -u "$tmp_dir/canonical" "$tmp_dir/cand" >&2 || true - rc=1 - fi - # The block being present is not enough: each installer must also actually - # invoke the helper exactly once (a deleted/broken call site would otherwise - # leave the install silently broken while the blocks stay "in sync"). - calls=$(grep -cE '^aenv_completion_install (install|uninstall) ' "$f" || true) - if [[ "$calls" -ne 1 ]]; then - echo "error: expected exactly one aenv_completion_install invocation in $f (found $calls)" >&2 - rc=1 - fi -done - -if [[ $rc -eq 0 ]]; then - echo "aenv_completion_install blocks in sync." -fi -exit "$rc" diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index e86aac1e..18cbb437 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -67,511 +67,6 @@ run_privileged() { fi } -# BEGIN aenv_completion_install -# (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, -# scripts/install.sh — verified by scripts/check-completion-sync.sh) -# -# All filesystem writes flow through ONE atomic-commit primitive -# (`_aenv_cc_commit`): the new content is staged to a temp file created IN the -# destination directory (same filesystem => an atomic rename), the destination's -# symlink is resolved (so the link is preserved, not replaced), and its mode -# (and, when running as root, ownership) is copied onto the temp file first. -# This guarantees every write site shares the same atomicity / symlink / -# metadata properties, so the pattern cannot drift between functions. -# -# NOTE: this does NOT preserve ACLs, extended attributes, or security labels -# (SELinux/AppArmor contexts) — only the POSIX mode bits, and ownership when -# we are root. Callers writing to files that carry such metadata should not -# assume it survives the rename. -# -# Every generated file/block also carries an aenv ownership marker so -# `uninstall` never deletes a file it did not create (see _AENV_CC_MARKER). - -_AENV_CC_MARKER="# managed by aenv-installer; do not edit (remove the whole file to opt out)" - -# Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the -# mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, -# BSD/macOS stat uses -f. -_aenv_cc_mode_octal() { - stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '0644' -} - -# Resolve $1 to the real file path it refers to when it is a symlink, so writes -# land on the target and preserve the link rather than replacing it. -# - Not a symlink: prints $1, returns 0. -# - Symlink, resolvable: prints the resolved absolute path, returns 0. -# - Symlink, NOT resolvable (broken link, no readlink at all): prints -# nothing and returns 1. Callers MUST check the return status and refuse -# to write rather than falling back to $1 — writing to $1 in that case -# would replace the symlink itself, silently breaking the "preserve the -# link" guarantee this whole module advertises. -_aenv_cc_resolve() { - local p="$1" link hops=0 - # Walk the symlink chain portably with a bound (cycle detection), so - # multi-hop chains resolve on BSD/macOS (no `readlink -f`) as well as GNU. - # Returns failure on an unreadable or broken symlink, so callers never mv - # over an intermediate link. A plain (non-symlink) path is returned as-is - # even when it does not yet exist, so first-install (which creates the file) - # is not blocked. - while [[ -L "$p" ]]; do - if ! link=$(readlink "$p" 2>/dev/null) || [[ -z "$link" ]]; then - return 1 - fi - [[ "$link" = /* ]] || link="${p%/*}/$link" - p="$link" - hops=$((hops + 1)) - [[ "$hops" -lt 40 ]] || return 1 - done - # If we followed at least one hop and landed on a non-existent path, the - # link chain is broken — refuse rather than mv into nothing. - [[ "$hops" -gt 0 && ! -e "$p" ]] && return 1 - printf '%s' "$p" - return 0 -} - -# Atomically publish a staged temp file as . -# $1 temp file path — MUST live in the same directory as 's RESOLVED -# target (caller's job) so the final rename is atomic and not a -# cross-filesystem copy+delete. -# $2 destination path (possibly a symlink; its target is replaced, the link -# itself is preserved). The destination's current mode — and, when -# running as root, its ownership — is copied onto the temp first (0644 -# default for a new file). -# Returns nonzero on failure (including an unresolvable symlink); the caller -# is responsible for cleaning up the temp in that case. -_aenv_cc_commit() { - local tmp="$1" dest="$2" - local target - if ! target="$(_aenv_cc_resolve "$dest")" || [[ -z "$target" ]]; then - return 1 - fi - chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true - # Preserve ownership when running as root. `chown --reference` is GNU-only, - # so read uid:gid portably (GNU `stat -c`, BSD/macOS `stat -f`) and chown - # explicitly; do NOT silently commit a root-owned temp over a user file. - if [[ $EUID -eq 0 && -e "$target" ]]; then - local ids - ids=$(stat -c '%u:%g' "$target" 2>/dev/null || stat -f '%u:%g' "$target" 2>/dev/null || true) - if [[ -n "$ids" ]] && ! chown "$ids" "$tmp" 2>/dev/null; then - return 1 - fi - fi - mv -f "$tmp" "$target" 2>/dev/null -} - -# Write a single completion loader file atomically, prefixed with the aenv -# ownership marker so a later uninstall can verify it still owns the file -# before deleting it. Non-fatal on I/O errors. -# $1 destination path -# $2 file mode (e.g. 0644) -# $3 loader content (one or more lines; the marker is prepended) -_aenv_cc_put() { - local path="$1" mode="$2" content="$3" - local dir="${path%/*}" - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 - return 0 - fi - local target_dir="${target%/*}" - if ! mkdir -p "$target_dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 - return 0 - fi - # Do not overwrite a pre-existing file we did not create (a hand-written or - # package-manager completion). Symmetric with _aenv_cc_rm_owned. - if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then - printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 - return 0 - fi - local tmp - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 - return 0 - } - if { printf '%s\n' "$_AENV_CC_MARKER"; printf '%s\n' "$content"; } > "$tmp" 2>/dev/null \ - && _aenv_cc_commit "$tmp" "$path"; then - chmod "$mode" "$path" 2>/dev/null || true - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not write %s\n' "$path" >&2 -} - -# Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: -# strictly alternating start/end pairs with no nesting, reordering, orphan -# markers, or unterminated start at EOF. Returns 1 otherwise. Used to gate both -# install idempotency and removal so a corrupted/partial block is never silently -# truncated and never auto-repaired at the cost of unrelated rc content. -_aenv_cc_rc_well_formed() { - awk ' - BEGIN { in_block = 0 } - /^# >>> aenv completion >>>$/ { if (in_block) exit 1; in_block = 1; next } - /^# <<< aenv completion <<<$/ { if (!in_block) exit 1; in_block = 0; next } - END { if (in_block) exit 1 } - ' "$1" 2>/dev/null -} - -# The canonical managed block content (including its start/end markers). -# Single source of truth used both to write a fresh block and to detect a -# stale one on reinstall/upgrade. -# -# The compinit call is now guarded on `compdef` already being defined, so a -# framework (oh-my-zsh, prezto, etc.) or an earlier rc section that already -# ran compinit is not forced to pay for a second (relatively expensive) run -# on every shell start. -_aenv_cc_zsh_block_canonical() { - printf '# >>> aenv completion >>>\n' - printf 'if command -v aenv >/dev/null 2>&1; then\n' - printf 'type compdef >/dev/null 2>&1 || { autoload -Uz compinit && compinit; }\n' - # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash - printf 'eval "$(aenv completion zsh)"\n' - printf 'fi\n' - printf '# <<< aenv completion <<<\n' -} - -# Print the currently-installed managed block (markers included) from $1, or -# nothing if there isn't one. Used to detect a stale block on reinstall. -_aenv_cc_zsh_block_current() { - awk ' - /^# >>> aenv completion >>>$/ { f = 1 } - f { print } - /^# <<< aenv completion <<<$/ { f = 0 } - ' "$1" 2>/dev/null -} - -# Serialize the full read-check-write of a zsh rc file with an flock-based -# lock so two concurrent installer runs (or install racing uninstall) cannot -# both observe "no marker" and both append, or otherwise interleave into a -# malformed/duplicated block. Best-effort: if flock isn't available we fall -# back to running unlocked rather than failing the (best-effort) completion -# install outright. -# $1 rc file path -# $2... function name + args to run inside the lock -_aenv_cc_with_zsh_lock() { - local rc="$1"; shift - # Lock the rc's OWN fd (read-only open: no truncation, and no sidecar lock - # file in the user's directory that a privileged run could be tricked into - # following as a symlink). Best-effort: fall back to unlocked where flock is - # missing or the rc does not yet exist (first install). - if command -v flock >/dev/null 2>&1 && [[ -e "$rc" ]]; then - ( - exec 200<"$rc" 2>/dev/null || { "$@"; exit; } - flock -w 10 200 2>/dev/null || \ - printf 'warn: aenv completion: could not lock %s (timed out); proceeding unlocked\n' "$rc" >&2 - "$@" - ) - else - "$@" - fi -} - -# Append (or, on upgrade, in-place replace) the regenerating zsh rc-snippet, -# idempotently and atomically. -# - No markers present: append the canonical block. -# - Well-formed block present, contents match canonical: no-op. -# - Well-formed block present, contents differ (e.g. upgrade changed the -# snippet): replace just the block, byte-for-byte, leaving everything -# else in the file untouched. -# - Malformed block present: warn and leave it for the user (auto-repair -# could delete unrelated rc lines). -# The full new rc (existing content, possibly with the block replaced, or -# + the appended block) is staged to a same-directory temp and committed by -# an atomic rename, so an interruption or I/O failure never leaves a -# partial/malformed block in the live rc. -# $1 rc file path -_aenv_cc_put_zsh_rc_impl() { - local rc="$1" - local dir="${rc%/*}" - local canonical - canonical="$(_aenv_cc_zsh_block_canonical)" - - if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then - if ! _aenv_cc_rc_well_formed "$rc"; then - printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 - return 0 - fi - local current - current="$(_aenv_cc_zsh_block_current "$rc")" - if [[ "$current" == "$canonical" ]]; then - return 0 # idempotent: a complete, up-to-date managed block already exists - fi - # Stale block: rewrite just that span in place, atomically. - local target tmp - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 - return 0 - } - if awk -v block="$canonical" ' - BEGIN { in_block = 0 } - /^# >>> aenv completion >>>$/ { print block; in_block = 1; next } - /^# <<< aenv completion <<<$/ { in_block = 0; next } - in_block { next } - { print } - ' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not update stale block in %s\n' "$rc" >&2 - return 0 - fi - - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local target tmp last_byte - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 - return 0 - } - # Stage existing content first (guarded), then add a separating newline if - # the existing content did not end in one, then the managed block. Each step - # returns nonzero on I/O failure so we never commit a partial result. - if [[ -s "$target" ]]; then - cat "$target" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } - last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } - fi - if ! printf '%s\n' "$canonical" >> "$tmp" 2>/dev/null; then - rm -f "$tmp" 2>/dev/null - printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 - return 0 - fi - _aenv_cc_commit "$tmp" "$rc" || { - rm -f "$tmp" 2>/dev/null - printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 - } -} - -_aenv_cc_put_zsh_rc() { - _aenv_cc_with_zsh_lock "$1" _aenv_cc_put_zsh_rc_impl "$1" -} - -# Generate the static zsh completion into a site-functions dir, prefixed with -# the aenv ownership marker (as a comment) so uninstall can verify ownership. -# $2 is the just-installed aenv binary (preferred over whatever is on PATH, -# which may be stale or absent). Generation goes through `_aenv_cc_commit`, -# so a failure or empty output never replaces an existing valid completion -# file. -# $1 destination _aenv path -# $2 aenv binary to invoke (default: aenv from PATH) -_aenv_cc_put_zsh_static() { - local path="$1" aenv_bin="${2:-aenv}" - local dir="${path%/*}" - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local gen=() - if [[ -x "$aenv_bin" ]]; then - gen=("$aenv_bin" completion zsh) - elif command -v aenv >/dev/null 2>&1; then - gen=(aenv completion zsh) - else - printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 - return 0 - fi - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 - return 0 - fi - if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then - printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 - return 0 - fi - local tmp - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 - return 0 - } - # Generate the completion body first and require it to be non-empty: a - # broken aenv that exits 0 with no bytes must not erase a working completion - # via the atomic rename. The ownership marker is appended AFTER this check - # (as a trailing comment) so the generated #compdef stays on line 1 — zsh - # only loads the function if #compdef is the first line. - if ! "${gen[@]}" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then - rm -f "$tmp" 2>/dev/null || true - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 - return 0 - fi - # Ensure a newline separates the completion body from the marker comment so - # a generator that omits a trailing newline does not fuse the marker onto - # the last shell statement. - local last_byte - last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - { [[ "$last_byte" == "0a" ]] || printf '\n'; printf '%s\n' "$_AENV_CC_MARKER"; } >> "$tmp" 2>/dev/null || { - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 - return 0 - } - if _aenv_cc_commit "$tmp" "$path"; then - chmod 0644 "$path" 2>/dev/null || true - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not commit static zsh file %s\n' "$path" >&2 -} - -# True if $1 is a plain file that starts with the aenv ownership marker — -# i.e. a file this installer created and is safe to remove. A pre-existing, -# hand-written, or package-manager-owned completion file at the same -# conventional path will NOT match, and is left alone. -_aenv_cc_owns() { - [[ -f "$1" ]] || return 1 - # Match the marker anywhere: bash/fish stubs carry it on line 1, while the - # static zsh file carries it as a trailing comment (its #compdef must stay - # on line 1 for zsh to load the function). - grep -qF -- "$_AENV_CC_MARKER" "$1" 2>/dev/null -} - -# Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it -# untouched so a user's own completion file, or one now owned by a package -# manager, is never silently deleted. Resolves symlinks before both the -# ownership check and the removal so an install/uninstall cycle through a -# symlinked completion path removes the managed TARGET we wrote, not the link. -_aenv_cc_rm_owned() { - local path="$1" - [[ -e "$path" ]] || return 0 - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is an unresolvable symlink; leaving it untouched\n' "$path" >&2 - return 0 - fi - if _aenv_cc_owns "$target"; then - rm -f "$target" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 - else - printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 - fi -} - -# Remove every well-formed aenv marker block from the zsh rc. Refuses to touch -# a file with a malformed (partial/nested/reordered/orphan) block. The -# rewrite is staged to a same-directory temp and committed by an atomic -# rename via `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a -# symlinked rc) the link itself are preserved, and a failed/partial awk never -# reaches the live file. Locked the same way as install to avoid racing a -# concurrent install/uninstall or hand-edit. -# $1 rc file path -_aenv_cc_rm_zsh_rc_impl() { - local rc="$1" - [[ -f "$rc" ]] || return 0 - grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 - if ! _aenv_cc_rc_well_formed "$rc"; then - printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 - return 0 - fi - local target tmp - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; leaving it untouched\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 - return 0 - } - if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 -} - -_aenv_cc_rm_zsh_rc() { - _aenv_cc_with_zsh_lock "$1" _aenv_cc_rm_zsh_rc_impl "$1" -} - -# Install or remove the aenv shell-completion loaders. -# -# aenv_completion_install install [--prefix=

] [--user] -# aenv_completion_install uninstall [--prefix=

] [--user] -# -# Always returns 0 so completion setup never aborts the surrounding binary -# installer; per-shell problems are reported as warnings on stderr. -aenv_completion_install() { - local action="" prefix="" user_mode=0 - while (($#)); do - case "$1" in - install|uninstall) action="$1"; shift ;; - --prefix=*) prefix="${1#--prefix=}"; shift ;; - --user) user_mode=1; shift ;; - *) printf 'warn: aenv completion: ignoring unknown argument %s\n' "$1" >&2; shift ;; - esac - done - - if [[ "$action" != "install" && "$action" != "uninstall" ]]; then - printf 'warn: aenv completion: expected an install or uninstall action; skipping\n' >&2 - return 0 - fi - - # Capture $HOME once, safely (set -u safe). It drives both mode detection - # and the user-mode destination paths, and must not be dereferenced bare. - local home="${HOME:-}" - - # Auto-select user mode for a bare invocation or a prefix under $HOME. - if [[ $user_mode -eq 0 ]]; then - if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then - user_mode=1 - fi - fi - - if [[ $user_mode -eq 1 && -z "$home" ]]; then - printf 'warn: aenv completion: user mode requested but HOME is unset; skipping\n' >&2 - return 0 - fi - - local bash_file fish_file zsh_file zsh_kind - if [[ $user_mode -eq 1 ]]; then - bash_file="${home}/.local/share/bash-completion/completions/aenv" - fish_file="${home}/.config/fish/completions/aenv.fish" - zsh_file="${home}/.zshrc" - zsh_kind="rc" - else - bash_file="${prefix}/share/bash-completion/completions/aenv" - fish_file="${prefix}/share/fish/vendor_completions.d/aenv.fish" - zsh_file="${prefix}/share/zsh/site-functions/_aenv" - zsh_kind="static" - fi - - if [[ "$action" == "install" ]]; then - # bash/fish loaders guard on aenv presence so a missing/uninstalled aenv - # is silent rather than erroring on every shell start (matches the zsh - # rc-snippet's `command -v aenv` guard). - _aenv_cc_put "$bash_file" 0644 'command -v aenv >/dev/null 2>&1 && source <(aenv completion bash)' - _aenv_cc_put "$fish_file" 0644 'type -q aenv; and aenv completion fish | source' - if [[ "$zsh_kind" == "rc" ]]; then - _aenv_cc_put_zsh_rc "$zsh_file" - else - _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" - fi - else - _aenv_cc_rm_owned "$bash_file" - _aenv_cc_rm_owned "$fish_file" - if [[ "$zsh_kind" == "rc" ]]; then - _aenv_cc_rm_zsh_rc "$zsh_file" - else - _aenv_cc_rm_owned "$zsh_file" - fi - fi - return 0 -} - -# END aenv_completion_install - if ((${#missing_packages[@]} > 0)); then echo "Installing required commands: ${missing_packages[*]} ..." if [[ "$OS" == "darwin" ]]; then @@ -670,13 +165,6 @@ fi echo "Installed: ${DEST}" -# Install regenerating shell-completion loaders (best-effort; never aborts the -# binary install). Derive the prefix from INSTALL_DIR with dirname so edge cases -# like INSTALL_DIR=/bin map to prefix=/ rather than an empty string (which would -# be misread as user mode). A prefix under $HOME selects user mode; otherwise -# system mode (/share). -aenv_completion_install install --prefix="$(dirname -- "$INSTALL_DIR")" - if ! command -v aenv &>/dev/null; then echo "" echo "Note: ${INSTALL_DIR} is not on your PATH." diff --git a/scripts/install.sh b/scripts/install.sh index 5b63e6a2..a2dbce88 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -63,511 +63,6 @@ curl_get() { curl -fsSL --retry 5 --retry-delay 10 --retry-max-time 60 "$@" } -# BEGIN aenv_completion_install -# (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, -# scripts/install.sh — verified by scripts/check-completion-sync.sh) -# -# All filesystem writes flow through ONE atomic-commit primitive -# (`_aenv_cc_commit`): the new content is staged to a temp file created IN the -# destination directory (same filesystem => an atomic rename), the destination's -# symlink is resolved (so the link is preserved, not replaced), and its mode -# (and, when running as root, ownership) is copied onto the temp file first. -# This guarantees every write site shares the same atomicity / symlink / -# metadata properties, so the pattern cannot drift between functions. -# -# NOTE: this does NOT preserve ACLs, extended attributes, or security labels -# (SELinux/AppArmor contexts) — only the POSIX mode bits, and ownership when -# we are root. Callers writing to files that carry such metadata should not -# assume it survives the rename. -# -# Every generated file/block also carries an aenv ownership marker so -# `uninstall` never deletes a file it did not create (see _AENV_CC_MARKER). - -_AENV_CC_MARKER="# managed by aenv-installer; do not edit (remove the whole file to opt out)" - -# Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the -# mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, -# BSD/macOS stat uses -f. -_aenv_cc_mode_octal() { - stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '0644' -} - -# Resolve $1 to the real file path it refers to when it is a symlink, so writes -# land on the target and preserve the link rather than replacing it. -# - Not a symlink: prints $1, returns 0. -# - Symlink, resolvable: prints the resolved absolute path, returns 0. -# - Symlink, NOT resolvable (broken link, no readlink at all): prints -# nothing and returns 1. Callers MUST check the return status and refuse -# to write rather than falling back to $1 — writing to $1 in that case -# would replace the symlink itself, silently breaking the "preserve the -# link" guarantee this whole module advertises. -_aenv_cc_resolve() { - local p="$1" link hops=0 - # Walk the symlink chain portably with a bound (cycle detection), so - # multi-hop chains resolve on BSD/macOS (no `readlink -f`) as well as GNU. - # Returns failure on an unreadable or broken symlink, so callers never mv - # over an intermediate link. A plain (non-symlink) path is returned as-is - # even when it does not yet exist, so first-install (which creates the file) - # is not blocked. - while [[ -L "$p" ]]; do - if ! link=$(readlink "$p" 2>/dev/null) || [[ -z "$link" ]]; then - return 1 - fi - [[ "$link" = /* ]] || link="${p%/*}/$link" - p="$link" - hops=$((hops + 1)) - [[ "$hops" -lt 40 ]] || return 1 - done - # If we followed at least one hop and landed on a non-existent path, the - # link chain is broken — refuse rather than mv into nothing. - [[ "$hops" -gt 0 && ! -e "$p" ]] && return 1 - printf '%s' "$p" - return 0 -} - -# Atomically publish a staged temp file as . -# $1 temp file path — MUST live in the same directory as 's RESOLVED -# target (caller's job) so the final rename is atomic and not a -# cross-filesystem copy+delete. -# $2 destination path (possibly a symlink; its target is replaced, the link -# itself is preserved). The destination's current mode — and, when -# running as root, its ownership — is copied onto the temp first (0644 -# default for a new file). -# Returns nonzero on failure (including an unresolvable symlink); the caller -# is responsible for cleaning up the temp in that case. -_aenv_cc_commit() { - local tmp="$1" dest="$2" - local target - if ! target="$(_aenv_cc_resolve "$dest")" || [[ -z "$target" ]]; then - return 1 - fi - chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true - # Preserve ownership when running as root. `chown --reference` is GNU-only, - # so read uid:gid portably (GNU `stat -c`, BSD/macOS `stat -f`) and chown - # explicitly; do NOT silently commit a root-owned temp over a user file. - if [[ $EUID -eq 0 && -e "$target" ]]; then - local ids - ids=$(stat -c '%u:%g' "$target" 2>/dev/null || stat -f '%u:%g' "$target" 2>/dev/null || true) - if [[ -n "$ids" ]] && ! chown "$ids" "$tmp" 2>/dev/null; then - return 1 - fi - fi - mv -f "$tmp" "$target" 2>/dev/null -} - -# Write a single completion loader file atomically, prefixed with the aenv -# ownership marker so a later uninstall can verify it still owns the file -# before deleting it. Non-fatal on I/O errors. -# $1 destination path -# $2 file mode (e.g. 0644) -# $3 loader content (one or more lines; the marker is prepended) -_aenv_cc_put() { - local path="$1" mode="$2" content="$3" - local dir="${path%/*}" - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 - return 0 - fi - local target_dir="${target%/*}" - if ! mkdir -p "$target_dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 - return 0 - fi - # Do not overwrite a pre-existing file we did not create (a hand-written or - # package-manager completion). Symmetric with _aenv_cc_rm_owned. - if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then - printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 - return 0 - fi - local tmp - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 - return 0 - } - if { printf '%s\n' "$_AENV_CC_MARKER"; printf '%s\n' "$content"; } > "$tmp" 2>/dev/null \ - && _aenv_cc_commit "$tmp" "$path"; then - chmod "$mode" "$path" 2>/dev/null || true - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not write %s\n' "$path" >&2 -} - -# Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: -# strictly alternating start/end pairs with no nesting, reordering, orphan -# markers, or unterminated start at EOF. Returns 1 otherwise. Used to gate both -# install idempotency and removal so a corrupted/partial block is never silently -# truncated and never auto-repaired at the cost of unrelated rc content. -_aenv_cc_rc_well_formed() { - awk ' - BEGIN { in_block = 0 } - /^# >>> aenv completion >>>$/ { if (in_block) exit 1; in_block = 1; next } - /^# <<< aenv completion <<<$/ { if (!in_block) exit 1; in_block = 0; next } - END { if (in_block) exit 1 } - ' "$1" 2>/dev/null -} - -# The canonical managed block content (including its start/end markers). -# Single source of truth used both to write a fresh block and to detect a -# stale one on reinstall/upgrade. -# -# The compinit call is now guarded on `compdef` already being defined, so a -# framework (oh-my-zsh, prezto, etc.) or an earlier rc section that already -# ran compinit is not forced to pay for a second (relatively expensive) run -# on every shell start. -_aenv_cc_zsh_block_canonical() { - printf '# >>> aenv completion >>>\n' - printf 'if command -v aenv >/dev/null 2>&1; then\n' - printf 'type compdef >/dev/null 2>&1 || { autoload -Uz compinit && compinit; }\n' - # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash - printf 'eval "$(aenv completion zsh)"\n' - printf 'fi\n' - printf '# <<< aenv completion <<<\n' -} - -# Print the currently-installed managed block (markers included) from $1, or -# nothing if there isn't one. Used to detect a stale block on reinstall. -_aenv_cc_zsh_block_current() { - awk ' - /^# >>> aenv completion >>>$/ { f = 1 } - f { print } - /^# <<< aenv completion <<<$/ { f = 0 } - ' "$1" 2>/dev/null -} - -# Serialize the full read-check-write of a zsh rc file with an flock-based -# lock so two concurrent installer runs (or install racing uninstall) cannot -# both observe "no marker" and both append, or otherwise interleave into a -# malformed/duplicated block. Best-effort: if flock isn't available we fall -# back to running unlocked rather than failing the (best-effort) completion -# install outright. -# $1 rc file path -# $2... function name + args to run inside the lock -_aenv_cc_with_zsh_lock() { - local rc="$1"; shift - # Lock the rc's OWN fd (read-only open: no truncation, and no sidecar lock - # file in the user's directory that a privileged run could be tricked into - # following as a symlink). Best-effort: fall back to unlocked where flock is - # missing or the rc does not yet exist (first install). - if command -v flock >/dev/null 2>&1 && [[ -e "$rc" ]]; then - ( - exec 200<"$rc" 2>/dev/null || { "$@"; exit; } - flock -w 10 200 2>/dev/null || \ - printf 'warn: aenv completion: could not lock %s (timed out); proceeding unlocked\n' "$rc" >&2 - "$@" - ) - else - "$@" - fi -} - -# Append (or, on upgrade, in-place replace) the regenerating zsh rc-snippet, -# idempotently and atomically. -# - No markers present: append the canonical block. -# - Well-formed block present, contents match canonical: no-op. -# - Well-formed block present, contents differ (e.g. upgrade changed the -# snippet): replace just the block, byte-for-byte, leaving everything -# else in the file untouched. -# - Malformed block present: warn and leave it for the user (auto-repair -# could delete unrelated rc lines). -# The full new rc (existing content, possibly with the block replaced, or -# + the appended block) is staged to a same-directory temp and committed by -# an atomic rename, so an interruption or I/O failure never leaves a -# partial/malformed block in the live rc. -# $1 rc file path -_aenv_cc_put_zsh_rc_impl() { - local rc="$1" - local dir="${rc%/*}" - local canonical - canonical="$(_aenv_cc_zsh_block_canonical)" - - if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then - if ! _aenv_cc_rc_well_formed "$rc"; then - printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 - return 0 - fi - local current - current="$(_aenv_cc_zsh_block_current "$rc")" - if [[ "$current" == "$canonical" ]]; then - return 0 # idempotent: a complete, up-to-date managed block already exists - fi - # Stale block: rewrite just that span in place, atomically. - local target tmp - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 - return 0 - } - if awk -v block="$canonical" ' - BEGIN { in_block = 0 } - /^# >>> aenv completion >>>$/ { print block; in_block = 1; next } - /^# <<< aenv completion <<<$/ { in_block = 0; next } - in_block { next } - { print } - ' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not update stale block in %s\n' "$rc" >&2 - return 0 - fi - - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local target tmp last_byte - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 - return 0 - } - # Stage existing content first (guarded), then add a separating newline if - # the existing content did not end in one, then the managed block. Each step - # returns nonzero on I/O failure so we never commit a partial result. - if [[ -s "$target" ]]; then - cat "$target" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } - last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } - fi - if ! printf '%s\n' "$canonical" >> "$tmp" 2>/dev/null; then - rm -f "$tmp" 2>/dev/null - printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 - return 0 - fi - _aenv_cc_commit "$tmp" "$rc" || { - rm -f "$tmp" 2>/dev/null - printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 - } -} - -_aenv_cc_put_zsh_rc() { - _aenv_cc_with_zsh_lock "$1" _aenv_cc_put_zsh_rc_impl "$1" -} - -# Generate the static zsh completion into a site-functions dir, prefixed with -# the aenv ownership marker (as a comment) so uninstall can verify ownership. -# $2 is the just-installed aenv binary (preferred over whatever is on PATH, -# which may be stale or absent). Generation goes through `_aenv_cc_commit`, -# so a failure or empty output never replaces an existing valid completion -# file. -# $1 destination _aenv path -# $2 aenv binary to invoke (default: aenv from PATH) -_aenv_cc_put_zsh_static() { - local path="$1" aenv_bin="${2:-aenv}" - local dir="${path%/*}" - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local gen=() - if [[ -x "$aenv_bin" ]]; then - gen=("$aenv_bin" completion zsh) - elif command -v aenv >/dev/null 2>&1; then - gen=(aenv completion zsh) - else - printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 - return 0 - fi - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 - return 0 - fi - if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then - printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 - return 0 - fi - local tmp - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 - return 0 - } - # Generate the completion body first and require it to be non-empty: a - # broken aenv that exits 0 with no bytes must not erase a working completion - # via the atomic rename. The ownership marker is appended AFTER this check - # (as a trailing comment) so the generated #compdef stays on line 1 — zsh - # only loads the function if #compdef is the first line. - if ! "${gen[@]}" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then - rm -f "$tmp" 2>/dev/null || true - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 - return 0 - fi - # Ensure a newline separates the completion body from the marker comment so - # a generator that omits a trailing newline does not fuse the marker onto - # the last shell statement. - local last_byte - last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - { [[ "$last_byte" == "0a" ]] || printf '\n'; printf '%s\n' "$_AENV_CC_MARKER"; } >> "$tmp" 2>/dev/null || { - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 - return 0 - } - if _aenv_cc_commit "$tmp" "$path"; then - chmod 0644 "$path" 2>/dev/null || true - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not commit static zsh file %s\n' "$path" >&2 -} - -# True if $1 is a plain file that starts with the aenv ownership marker — -# i.e. a file this installer created and is safe to remove. A pre-existing, -# hand-written, or package-manager-owned completion file at the same -# conventional path will NOT match, and is left alone. -_aenv_cc_owns() { - [[ -f "$1" ]] || return 1 - # Match the marker anywhere: bash/fish stubs carry it on line 1, while the - # static zsh file carries it as a trailing comment (its #compdef must stay - # on line 1 for zsh to load the function). - grep -qF -- "$_AENV_CC_MARKER" "$1" 2>/dev/null -} - -# Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it -# untouched so a user's own completion file, or one now owned by a package -# manager, is never silently deleted. Resolves symlinks before both the -# ownership check and the removal so an install/uninstall cycle through a -# symlinked completion path removes the managed TARGET we wrote, not the link. -_aenv_cc_rm_owned() { - local path="$1" - [[ -e "$path" ]] || return 0 - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is an unresolvable symlink; leaving it untouched\n' "$path" >&2 - return 0 - fi - if _aenv_cc_owns "$target"; then - rm -f "$target" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 - else - printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 - fi -} - -# Remove every well-formed aenv marker block from the zsh rc. Refuses to touch -# a file with a malformed (partial/nested/reordered/orphan) block. The -# rewrite is staged to a same-directory temp and committed by an atomic -# rename via `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a -# symlinked rc) the link itself are preserved, and a failed/partial awk never -# reaches the live file. Locked the same way as install to avoid racing a -# concurrent install/uninstall or hand-edit. -# $1 rc file path -_aenv_cc_rm_zsh_rc_impl() { - local rc="$1" - [[ -f "$rc" ]] || return 0 - grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 - if ! _aenv_cc_rc_well_formed "$rc"; then - printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 - return 0 - fi - local target tmp - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; leaving it untouched\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 - return 0 - } - if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 -} - -_aenv_cc_rm_zsh_rc() { - _aenv_cc_with_zsh_lock "$1" _aenv_cc_rm_zsh_rc_impl "$1" -} - -# Install or remove the aenv shell-completion loaders. -# -# aenv_completion_install install [--prefix=

] [--user] -# aenv_completion_install uninstall [--prefix=

] [--user] -# -# Always returns 0 so completion setup never aborts the surrounding binary -# installer; per-shell problems are reported as warnings on stderr. -aenv_completion_install() { - local action="" prefix="" user_mode=0 - while (($#)); do - case "$1" in - install|uninstall) action="$1"; shift ;; - --prefix=*) prefix="${1#--prefix=}"; shift ;; - --user) user_mode=1; shift ;; - *) printf 'warn: aenv completion: ignoring unknown argument %s\n' "$1" >&2; shift ;; - esac - done - - if [[ "$action" != "install" && "$action" != "uninstall" ]]; then - printf 'warn: aenv completion: expected an install or uninstall action; skipping\n' >&2 - return 0 - fi - - # Capture $HOME once, safely (set -u safe). It drives both mode detection - # and the user-mode destination paths, and must not be dereferenced bare. - local home="${HOME:-}" - - # Auto-select user mode for a bare invocation or a prefix under $HOME. - if [[ $user_mode -eq 0 ]]; then - if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then - user_mode=1 - fi - fi - - if [[ $user_mode -eq 1 && -z "$home" ]]; then - printf 'warn: aenv completion: user mode requested but HOME is unset; skipping\n' >&2 - return 0 - fi - - local bash_file fish_file zsh_file zsh_kind - if [[ $user_mode -eq 1 ]]; then - bash_file="${home}/.local/share/bash-completion/completions/aenv" - fish_file="${home}/.config/fish/completions/aenv.fish" - zsh_file="${home}/.zshrc" - zsh_kind="rc" - else - bash_file="${prefix}/share/bash-completion/completions/aenv" - fish_file="${prefix}/share/fish/vendor_completions.d/aenv.fish" - zsh_file="${prefix}/share/zsh/site-functions/_aenv" - zsh_kind="static" - fi - - if [[ "$action" == "install" ]]; then - # bash/fish loaders guard on aenv presence so a missing/uninstalled aenv - # is silent rather than erroring on every shell start (matches the zsh - # rc-snippet's `command -v aenv` guard). - _aenv_cc_put "$bash_file" 0644 'command -v aenv >/dev/null 2>&1 && source <(aenv completion bash)' - _aenv_cc_put "$fish_file" 0644 'type -q aenv; and aenv completion fish | source' - if [[ "$zsh_kind" == "rc" ]]; then - _aenv_cc_put_zsh_rc "$zsh_file" - else - _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" - fi - else - _aenv_cc_rm_owned "$bash_file" - _aenv_cc_rm_owned "$fish_file" - if [[ "$zsh_kind" == "rc" ]]; then - _aenv_cc_rm_zsh_rc "$zsh_file" - else - _aenv_cc_rm_owned "$zsh_file" - fi - fi - return 0 -} - -# END aenv_completion_install - missing_packages=() command -v curl >/dev/null 2>&1 || missing_packages+=(curl) command -v jq >/dev/null 2>&1 || missing_packages+=(jq) @@ -675,10 +170,6 @@ echo "Downloading aenv CLI ..." download_release_asset "aenv-linux-${ARCH_TAG}" "$tmp_cli" sudo install -m 0755 "$tmp_cli" "${INSTALL_DIR}/aenv" -# Install regenerating shell-completion loaders (best-effort; never aborts the -# install). System-wide install -> system mode writes /share loaders. -aenv_completion_install install --prefix="${INSTALL_DIR%/*}" - # --------------------------------------------------------------------------- # 2. Install the server # --------------------------------------------------------------------------- diff --git a/scripts/shell-completion.sh b/scripts/shell-completion.sh deleted file mode 100755 index 625bac56..00000000 --- a/scripts/shell-completion.sh +++ /dev/null @@ -1,556 +0,0 @@ -#!/usr/bin/env bash -# Manage lightweight, regenerating shell-completion loaders for the `aenv` CLI. -# -# The loaders do NOT cache a generated completion script. Instead they invoke -# `aenv completion ` at shell start (fish/zsh-user) or on first `aenv -# ` (bash lazy-loading), so the completion always matches the currently -# installed `aenv` binary and never goes stale when the CLI is upgraded. -# -# Usage: -# ./scripts/shell-completion.sh install [--prefix=

] [--user] -# ./scripts/shell-completion.sh uninstall [--prefix=

] [--user] -# -# --user Force user-local mode: writes under $HOME and appends an -# rc-snippet to ~/.zshrc. -# --prefix=

System mode (writes under

/share) unless

is under -# $HOME, in which case user mode is auto-selected. -# -# When neither flag is given, defaults to user mode so a bare run never -# requires root. All installers pass an explicit flag. -# -# Destinations: -# user bash: ~/.local/share/bash-completion/completions/aenv -# fish: ~/.config/fish/completions/aenv.fish -# zsh: rc-snippet in ~/.zshrc (regenerates every shell start) -# system bash:

/share/bash-completion/completions/aenv -# fish:

/share/fish/vendor_completions.d/aenv.fish -# zsh: static

/share/zsh/site-functions/_aenv (system installs are -# refreshed by re-running the installer, so a one-shot static -# file avoids a root-owned edit of every user's rc) -# -# The `aenv_completion_install` function (and its `_aenv_cc_*` helpers) below -# is the single source of truth. It is inlined verbatim into -# scripts/install-cli.sh and scripts/install.sh; scripts/check-completion-sync.sh -# enforces that the three copies stay byte-identical. -set -euo pipefail - -# BEGIN aenv_completion_install -# (keep in sync across scripts/shell-completion.sh, scripts/install-cli.sh, -# scripts/install.sh — verified by scripts/check-completion-sync.sh) -# -# All filesystem writes flow through ONE atomic-commit primitive -# (`_aenv_cc_commit`): the new content is staged to a temp file created IN the -# destination directory (same filesystem => an atomic rename), the destination's -# symlink is resolved (so the link is preserved, not replaced), and its mode -# (and, when running as root, ownership) is copied onto the temp file first. -# This guarantees every write site shares the same atomicity / symlink / -# metadata properties, so the pattern cannot drift between functions. -# -# NOTE: this does NOT preserve ACLs, extended attributes, or security labels -# (SELinux/AppArmor contexts) — only the POSIX mode bits, and ownership when -# we are root. Callers writing to files that carry such metadata should not -# assume it survives the rename. -# -# Every generated file/block also carries an aenv ownership marker so -# `uninstall` never deletes a file it did not create (see _AENV_CC_MARKER). - -_AENV_CC_MARKER="# managed by aenv-installer; do not edit (remove the whole file to opt out)" - -# Portable octal mode of an existing file (e.g. 644); defaults to 0644 when the -# mode cannot be read (e.g. the file does not yet exist). GNU stat uses -c, -# BSD/macOS stat uses -f. -_aenv_cc_mode_octal() { - stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || printf '0644' -} - -# Resolve $1 to the real file path it refers to when it is a symlink, so writes -# land on the target and preserve the link rather than replacing it. -# - Not a symlink: prints $1, returns 0. -# - Symlink, resolvable: prints the resolved absolute path, returns 0. -# - Symlink, NOT resolvable (broken link, no readlink at all): prints -# nothing and returns 1. Callers MUST check the return status and refuse -# to write rather than falling back to $1 — writing to $1 in that case -# would replace the symlink itself, silently breaking the "preserve the -# link" guarantee this whole module advertises. -_aenv_cc_resolve() { - local p="$1" link hops=0 - # Walk the symlink chain portably with a bound (cycle detection), so - # multi-hop chains resolve on BSD/macOS (no `readlink -f`) as well as GNU. - # Returns failure on an unreadable or broken symlink, so callers never mv - # over an intermediate link. A plain (non-symlink) path is returned as-is - # even when it does not yet exist, so first-install (which creates the file) - # is not blocked. - while [[ -L "$p" ]]; do - if ! link=$(readlink "$p" 2>/dev/null) || [[ -z "$link" ]]; then - return 1 - fi - [[ "$link" = /* ]] || link="${p%/*}/$link" - p="$link" - hops=$((hops + 1)) - [[ "$hops" -lt 40 ]] || return 1 - done - # If we followed at least one hop and landed on a non-existent path, the - # link chain is broken — refuse rather than mv into nothing. - [[ "$hops" -gt 0 && ! -e "$p" ]] && return 1 - printf '%s' "$p" - return 0 -} - -# Atomically publish a staged temp file as . -# $1 temp file path — MUST live in the same directory as 's RESOLVED -# target (caller's job) so the final rename is atomic and not a -# cross-filesystem copy+delete. -# $2 destination path (possibly a symlink; its target is replaced, the link -# itself is preserved). The destination's current mode — and, when -# running as root, its ownership — is copied onto the temp first (0644 -# default for a new file). -# Returns nonzero on failure (including an unresolvable symlink); the caller -# is responsible for cleaning up the temp in that case. -_aenv_cc_commit() { - local tmp="$1" dest="$2" - local target - if ! target="$(_aenv_cc_resolve "$dest")" || [[ -z "$target" ]]; then - return 1 - fi - chmod "$(_aenv_cc_mode_octal "$target")" "$tmp" 2>/dev/null || chmod 0644 "$tmp" 2>/dev/null || true - # Preserve ownership when running as root. `chown --reference` is GNU-only, - # so read uid:gid portably (GNU `stat -c`, BSD/macOS `stat -f`) and chown - # explicitly; do NOT silently commit a root-owned temp over a user file. - if [[ $EUID -eq 0 && -e "$target" ]]; then - local ids - ids=$(stat -c '%u:%g' "$target" 2>/dev/null || stat -f '%u:%g' "$target" 2>/dev/null || true) - if [[ -n "$ids" ]] && ! chown "$ids" "$tmp" 2>/dev/null; then - return 1 - fi - fi - mv -f "$tmp" "$target" 2>/dev/null -} - -# Write a single completion loader file atomically, prefixed with the aenv -# ownership marker so a later uninstall can verify it still owns the file -# before deleting it. Non-fatal on I/O errors. -# $1 destination path -# $2 file mode (e.g. 0644) -# $3 loader content (one or more lines; the marker is prepended) -_aenv_cc_put() { - local path="$1" mode="$2" content="$3" - local dir="${path%/*}" - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 - return 0 - fi - local target_dir="${target%/*}" - if ! mkdir -p "$target_dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$target_dir" >&2 - return 0 - fi - # Do not overwrite a pre-existing file we did not create (a hand-written or - # package-manager completion). Symmetric with _aenv_cc_rm_owned. - if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then - printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 - return 0 - fi - local tmp - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$path" >&2 - return 0 - } - if { printf '%s\n' "$_AENV_CC_MARKER"; printf '%s\n' "$content"; } > "$tmp" 2>/dev/null \ - && _aenv_cc_commit "$tmp" "$path"; then - chmod "$mode" "$path" 2>/dev/null || true - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not write %s\n' "$path" >&2 -} - -# Return 0 (exit status) if $1's aenv marker blocks — if any — are well-formed: -# strictly alternating start/end pairs with no nesting, reordering, orphan -# markers, or unterminated start at EOF. Returns 1 otherwise. Used to gate both -# install idempotency and removal so a corrupted/partial block is never silently -# truncated and never auto-repaired at the cost of unrelated rc content. -_aenv_cc_rc_well_formed() { - awk ' - BEGIN { in_block = 0 } - /^# >>> aenv completion >>>$/ { if (in_block) exit 1; in_block = 1; next } - /^# <<< aenv completion <<<$/ { if (!in_block) exit 1; in_block = 0; next } - END { if (in_block) exit 1 } - ' "$1" 2>/dev/null -} - -# The canonical managed block content (including its start/end markers). -# Single source of truth used both to write a fresh block and to detect a -# stale one on reinstall/upgrade. -# -# The compinit call is now guarded on `compdef` already being defined, so a -# framework (oh-my-zsh, prezto, etc.) or an earlier rc section that already -# ran compinit is not forced to pay for a second (relatively expensive) run -# on every shell start. -_aenv_cc_zsh_block_canonical() { - printf '# >>> aenv completion >>>\n' - printf 'if command -v aenv >/dev/null 2>&1; then\n' - printf 'type compdef >/dev/null 2>&1 || { autoload -Uz compinit && compinit; }\n' - # shellcheck disable=SC2016 # $(...) is literal text for zsh to eval, not bash - printf 'eval "$(aenv completion zsh)"\n' - printf 'fi\n' - printf '# <<< aenv completion <<<\n' -} - -# Print the currently-installed managed block (markers included) from $1, or -# nothing if there isn't one. Used to detect a stale block on reinstall. -_aenv_cc_zsh_block_current() { - awk ' - /^# >>> aenv completion >>>$/ { f = 1 } - f { print } - /^# <<< aenv completion <<<$/ { f = 0 } - ' "$1" 2>/dev/null -} - -# Serialize the full read-check-write of a zsh rc file with an flock-based -# lock so two concurrent installer runs (or install racing uninstall) cannot -# both observe "no marker" and both append, or otherwise interleave into a -# malformed/duplicated block. Best-effort: if flock isn't available we fall -# back to running unlocked rather than failing the (best-effort) completion -# install outright. -# $1 rc file path -# $2... function name + args to run inside the lock -_aenv_cc_with_zsh_lock() { - local rc="$1"; shift - # Lock the rc's OWN fd (read-only open: no truncation, and no sidecar lock - # file in the user's directory that a privileged run could be tricked into - # following as a symlink). Best-effort: fall back to unlocked where flock is - # missing or the rc does not yet exist (first install). - if command -v flock >/dev/null 2>&1 && [[ -e "$rc" ]]; then - ( - exec 200<"$rc" 2>/dev/null || { "$@"; exit; } - flock -w 10 200 2>/dev/null || \ - printf 'warn: aenv completion: could not lock %s (timed out); proceeding unlocked\n' "$rc" >&2 - "$@" - ) - else - "$@" - fi -} - -# Append (or, on upgrade, in-place replace) the regenerating zsh rc-snippet, -# idempotently and atomically. -# - No markers present: append the canonical block. -# - Well-formed block present, contents match canonical: no-op. -# - Well-formed block present, contents differ (e.g. upgrade changed the -# snippet): replace just the block, byte-for-byte, leaving everything -# else in the file untouched. -# - Malformed block present: warn and leave it for the user (auto-repair -# could delete unrelated rc lines). -# The full new rc (existing content, possibly with the block replaced, or -# + the appended block) is staged to a same-directory temp and committed by -# an atomic rename, so an interruption or I/O failure never leaves a -# partial/malformed block in the live rc. -# $1 rc file path -_aenv_cc_put_zsh_rc_impl() { - local rc="$1" - local dir="${rc%/*}" - local canonical - canonical="$(_aenv_cc_zsh_block_canonical)" - - if [[ -f "$rc" ]] && grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null; then - if ! _aenv_cc_rc_well_formed "$rc"; then - printf 'warn: aenv completion: malformed marker block already in %s; leaving it untouched (remove it manually to regenerate)\n' "$rc" >&2 - return 0 - fi - local current - current="$(_aenv_cc_zsh_block_current "$rc")" - if [[ "$current" == "$canonical" ]]; then - return 0 # idempotent: a complete, up-to-date managed block already exists - fi - # Stale block: rewrite just that span in place, atomically. - local target tmp - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 - return 0 - } - if awk -v block="$canonical" ' - BEGIN { in_block = 0 } - /^# >>> aenv completion >>>$/ { print block; in_block = 1; next } - /^# <<< aenv completion <<<$/ { in_block = 0; next } - in_block { next } - { print } - ' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not update stale block in %s\n' "$rc" >&2 - return 0 - fi - - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local target tmp last_byte - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s\n' "$rc" >&2 - return 0 - } - # Stage existing content first (guarded), then add a separating newline if - # the existing content did not end in one, then the managed block. Each step - # returns nonzero on I/O failure so we never commit a partial result. - if [[ -s "$target" ]]; then - cat "$target" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } - last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - [[ "$last_byte" == "0a" ]] || printf '\n' >> "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2; return 0; } - fi - if ! printf '%s\n' "$canonical" >> "$tmp" 2>/dev/null; then - rm -f "$tmp" 2>/dev/null - printf 'warn: aenv completion: could not stage %s\n' "$rc" >&2 - return 0 - fi - _aenv_cc_commit "$tmp" "$rc" || { - rm -f "$tmp" 2>/dev/null - printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 - } -} - -_aenv_cc_put_zsh_rc() { - _aenv_cc_with_zsh_lock "$1" _aenv_cc_put_zsh_rc_impl "$1" -} - -# Generate the static zsh completion into a site-functions dir, prefixed with -# the aenv ownership marker (as a comment) so uninstall can verify ownership. -# $2 is the just-installed aenv binary (preferred over whatever is on PATH, -# which may be stale or absent). Generation goes through `_aenv_cc_commit`, -# so a failure or empty output never replaces an existing valid completion -# file. -# $1 destination _aenv path -# $2 aenv binary to invoke (default: aenv from PATH) -_aenv_cc_put_zsh_static() { - local path="$1" aenv_bin="${2:-aenv}" - local dir="${path%/*}" - if ! mkdir -p "$dir" 2>/dev/null; then - printf 'warn: aenv completion: could not create directory %s\n' "$dir" >&2 - return 0 - fi - local gen=() - if [[ -x "$aenv_bin" ]]; then - gen=("$aenv_bin" completion zsh) - elif command -v aenv >/dev/null 2>&1; then - gen=(aenv completion zsh) - else - printf 'warn: aenv completion: aenv not found (%s not executable and aenv not on PATH); skipping static zsh file %s\n' "$aenv_bin" "$path" >&2 - return 0 - fi - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is a symlink that could not be resolved; skipping\n' "$path" >&2 - return 0 - fi - if [[ -e "$target" ]] && ! _aenv_cc_owns "$target"; then - printf 'warn: aenv completion: %s already exists and is not aenv-managed; leaving it untouched\n' "$path" >&2 - return 0 - fi - local tmp - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s; skipping static zsh\n' "$path" >&2 - return 0 - } - # Generate the completion body first and require it to be non-empty: a - # broken aenv that exits 0 with no bytes must not erase a working completion - # via the atomic rename. The ownership marker is appended AFTER this check - # (as a trailing comment) so the generated #compdef stays on line 1 — zsh - # only loads the function if #compdef is the first line. - if ! "${gen[@]}" > "$tmp" 2>/dev/null || [[ ! -s "$tmp" ]]; then - rm -f "$tmp" 2>/dev/null || true - # shellcheck disable=SC2016 # backticks are literal text in a warning - printf 'warn: aenv completion: `aenv completion zsh` failed or produced no output; skipping %s\n' "$path" >&2 - return 0 - fi - # Ensure a newline separates the completion body from the marker comment so - # a generator that omits a trailing newline does not fuse the marker onto - # the last shell statement. - local last_byte - last_byte=$(tail -c 1 "$tmp" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') || last_byte="" - { [[ "$last_byte" == "0a" ]] || printf '\n'; printf '%s\n' "$_AENV_CC_MARKER"; } >> "$tmp" 2>/dev/null || { - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not stage ownership marker for %s\n' "$path" >&2 - return 0 - } - if _aenv_cc_commit "$tmp" "$path"; then - chmod 0644 "$path" 2>/dev/null || true - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not commit static zsh file %s\n' "$path" >&2 -} - -# True if $1 is a plain file that starts with the aenv ownership marker — -# i.e. a file this installer created and is safe to remove. A pre-existing, -# hand-written, or package-manager-owned completion file at the same -# conventional path will NOT match, and is left alone. -_aenv_cc_owns() { - [[ -f "$1" ]] || return 1 - # Match the marker anywhere: bash/fish stubs carry it on line 1, while the - # static zsh file carries it as a trailing comment (its #compdef must stay - # on line 1 for zsh to load the function). - grep -qF -- "$_AENV_CC_MARKER" "$1" 2>/dev/null -} - -# Remove $1 only if we own it (see _aenv_cc_owns); otherwise warn and leave it -# untouched so a user's own completion file, or one now owned by a package -# manager, is never silently deleted. Resolves symlinks before both the -# ownership check and the removal so an install/uninstall cycle through a -# symlinked completion path removes the managed TARGET we wrote, not the link. -_aenv_cc_rm_owned() { - local path="$1" - [[ -e "$path" ]] || return 0 - local target - if ! target="$(_aenv_cc_resolve "$path")" || [[ -z "$target" ]]; then - printf 'warn: aenv completion: %s is an unresolvable symlink; leaving it untouched\n' "$path" >&2 - return 0 - fi - if _aenv_cc_owns "$target"; then - rm -f "$target" 2>/dev/null || printf 'warn: aenv completion: could not remove %s\n' "$path" >&2 - else - printf 'warn: aenv completion: %s was not installed by aenv (no ownership marker); leaving it untouched\n' "$path" >&2 - fi -} - -# Remove every well-formed aenv marker block from the zsh rc. Refuses to touch -# a file with a malformed (partial/nested/reordered/orphan) block. The -# rewrite is staged to a same-directory temp and committed by an atomic -# rename via `_aenv_cc_commit`, so the rc's inode/mode/ownership and (for a -# symlinked rc) the link itself are preserved, and a failed/partial awk never -# reaches the live file. Locked the same way as install to avoid racing a -# concurrent install/uninstall or hand-edit. -# $1 rc file path -_aenv_cc_rm_zsh_rc_impl() { - local rc="$1" - [[ -f "$rc" ]] || return 0 - grep -qE '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$rc" 2>/dev/null || return 0 - if ! _aenv_cc_rc_well_formed "$rc"; then - printf 'warn: aenv completion: malformed marker block in %s; leaving it untouched\n' "$rc" >&2 - return 0 - fi - local target tmp - target="$(_aenv_cc_resolve "$rc")" || { - printf 'warn: aenv completion: %s is a symlink that could not be resolved; leaving it untouched\n' "$rc" >&2 - return 0 - } - tmp="$(mktemp "${target}.XXXXXX" 2>/dev/null)" || { - printf 'warn: aenv completion: could not create temp file near %s; leaving it untouched\n' "$rc" >&2 - return 0 - } - if awk '/^# >>> aenv completion >>>$/,/^# <<< aenv completion <<<$/ { next } { print }' "$target" > "$tmp" 2>/dev/null && _aenv_cc_commit "$tmp" "$rc"; then - return 0 - fi - rm -f "$tmp" 2>/dev/null || true - printf 'warn: aenv completion: could not update %s\n' "$rc" >&2 -} - -_aenv_cc_rm_zsh_rc() { - _aenv_cc_with_zsh_lock "$1" _aenv_cc_rm_zsh_rc_impl "$1" -} - -# Install or remove the aenv shell-completion loaders. -# -# aenv_completion_install install [--prefix=

] [--user] -# aenv_completion_install uninstall [--prefix=

] [--user] -# -# Always returns 0 so completion setup never aborts the surrounding binary -# installer; per-shell problems are reported as warnings on stderr. -aenv_completion_install() { - local action="" prefix="" user_mode=0 - while (($#)); do - case "$1" in - install|uninstall) action="$1"; shift ;; - --prefix=*) prefix="${1#--prefix=}"; shift ;; - --user) user_mode=1; shift ;; - *) printf 'warn: aenv completion: ignoring unknown argument %s\n' "$1" >&2; shift ;; - esac - done - - if [[ "$action" != "install" && "$action" != "uninstall" ]]; then - printf 'warn: aenv completion: expected an install or uninstall action; skipping\n' >&2 - return 0 - fi - - # Capture $HOME once, safely (set -u safe). It drives both mode detection - # and the user-mode destination paths, and must not be dereferenced bare. - local home="${HOME:-}" - - # Auto-select user mode for a bare invocation or a prefix under $HOME. - if [[ $user_mode -eq 0 ]]; then - if [[ -z "$prefix" || ( -n "$home" && ( "$prefix" == "$home" || "$prefix" == "$home"/* ) ) ]]; then - user_mode=1 - fi - fi - - if [[ $user_mode -eq 1 && -z "$home" ]]; then - printf 'warn: aenv completion: user mode requested but HOME is unset; skipping\n' >&2 - return 0 - fi - - local bash_file fish_file zsh_file zsh_kind - if [[ $user_mode -eq 1 ]]; then - bash_file="${home}/.local/share/bash-completion/completions/aenv" - fish_file="${home}/.config/fish/completions/aenv.fish" - zsh_file="${home}/.zshrc" - zsh_kind="rc" - else - bash_file="${prefix}/share/bash-completion/completions/aenv" - fish_file="${prefix}/share/fish/vendor_completions.d/aenv.fish" - zsh_file="${prefix}/share/zsh/site-functions/_aenv" - zsh_kind="static" - fi - - if [[ "$action" == "install" ]]; then - # bash/fish loaders guard on aenv presence so a missing/uninstalled aenv - # is silent rather than erroring on every shell start (matches the zsh - # rc-snippet's `command -v aenv` guard). - _aenv_cc_put "$bash_file" 0644 'command -v aenv >/dev/null 2>&1 && source <(aenv completion bash)' - _aenv_cc_put "$fish_file" 0644 'type -q aenv; and aenv completion fish | source' - if [[ "$zsh_kind" == "rc" ]]; then - _aenv_cc_put_zsh_rc "$zsh_file" - else - _aenv_cc_put_zsh_static "$zsh_file" "${prefix}/bin/aenv" - fi - else - _aenv_cc_rm_owned "$bash_file" - _aenv_cc_rm_owned "$fish_file" - if [[ "$zsh_kind" == "rc" ]]; then - _aenv_cc_rm_zsh_rc "$zsh_file" - else - _aenv_cc_rm_owned "$zsh_file" - fi - fi - return 0 -} - -# END aenv_completion_install - -usage() { - cat <<'EOF' -Usage: shell-completion.sh [--prefix=

] [--user] - -Install or remove regenerating shell-completion loaders (bash, zsh, fish) for -the aenv CLI. See the header comment for destination details. -EOF -} - -if [[ $# -lt 1 ]]; then - usage >&2 - exit 2 -fi - -aenv_completion_install "$@" diff --git a/scripts/tests/verify-shell-completion.sh b/scripts/tests/verify-shell-completion.sh deleted file mode 100755 index c3835673..00000000 --- a/scripts/tests/verify-shell-completion.sh +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env bash -# Functional test for the aenv shell-completion loader installer. -# -# Does not require a real aenv binary: a stub `aenv` is placed on PATH so the -# static-zsh generation path is exercised end-to-end. Run via -# `make check-shell-completion` or directly with `bash scripts/tests/verify-shell-completion.sh`. -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -helper="$repo_root/scripts/shell-completion.sh" - -tmp_root="$(mktemp -d)" -trap 'rm -rf "$tmp_root"' EXIT - -fake_home="$tmp_root/home" -fake_bin="$tmp_root/bin" -sys_prefix="$tmp_root/sys" -mkdir -p "$fake_home" "$fake_bin" "$sys_prefix" - -# Stub aenv so `aenv completion ` succeeds during static-zsh generation. -cat > "$fake_bin/aenv" <<'EOF' -#!/usr/bin/env bash -case "${1:-}" in - completion) echo "# fake aenv completion for ${2:-?}" ;; - *) echo "fake aenv" ;; -esac -EOF -chmod +x "$fake_bin/aenv" -export PATH="$fake_bin:$PATH" - -# A PATH containing only the utilities the helper needs and NO aenv, so the -# "aenv missing" case is hermetic regardless of what is installed on the host -# (no reliance on /usr/bin/aenv or /bin/aenv existing or not). `bash` is -# included so the test can launch the helper under this restricted PATH. -hermetic_bin="$tmp_root/hermetic-bin" -mkdir -p "$hermetic_bin" -for u in bash mkdir grep awk tail od tr mktemp cat chmod rm mv; do - ln -s "$(command -v "$u")" "$hermetic_bin/$u" -done - -fail() { echo "FAIL: $*" >&2; exit 1; } -assert_contains() { # file needle - [[ -f "$1" ]] || fail "expected file $1 to exist" - grep -q -- "$2" "$1" || fail "expected $1 to contain: $2" -} -assert_absent() { # path - [[ ! -e "$1" ]] || fail "expected $1 to be absent, but it exists" -} -assert_rc_clean() { # rc-file - [[ ! -f "$1" ]] || ! grep -Eq '^# (>>> aenv completion >>>|<<< aenv completion <<<)$' "$1" \ - || fail "expected no aenv markers in $1" -} -marker_count() { # rc-file -> count - if [[ -f "$1" ]]; then - grep -c '^# >>> aenv completion >>>$' "$1" || true - else - echo 0 - fi -} - -# --------------------------------------------------------------------------- -# Test 1: user mode -# --------------------------------------------------------------------------- -echo "==> user-mode install" -HOME="$fake_home" bash "$helper" install --user -bash_file="$fake_home/.local/share/bash-completion/completions/aenv" -fish_file="$fake_home/.config/fish/completions/aenv.fish" -zshrc="$fake_home/.zshrc" -assert_contains "$bash_file" 'source <(aenv completion bash)' -assert_contains "$fish_file" 'aenv completion fish | source' -# shellcheck disable=SC2016 # searching for a literal $(...) string in the rc -assert_contains "$zshrc" 'eval "$(aenv completion zsh)"' -[[ "$(marker_count "$zshrc")" == "1" ]] || fail "expected exactly one marker block after install" - -echo "==> user-mode install is idempotent" -HOME="$fake_home" bash "$helper" install --user -[[ "$(marker_count "$zshrc")" == "1" ]] || fail "re-install appended a duplicate marker block" - -echo "==> user-mode uninstall" -HOME="$fake_home" bash "$helper" uninstall --user -assert_absent "$bash_file" -assert_absent "$fish_file" -assert_rc_clean "$zshrc" - -# --------------------------------------------------------------------------- -# Test 2: system mode -# --------------------------------------------------------------------------- -echo "==> system-mode install" -HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" -sys_bash="$sys_prefix/share/bash-completion/completions/aenv" -sys_fish="$sys_prefix/share/fish/vendor_completions.d/aenv.fish" -sys_zsh="$sys_prefix/share/zsh/site-functions/_aenv" -assert_contains "$sys_bash" 'source <(aenv completion bash)' -assert_contains "$sys_fish" 'aenv completion fish | source' -assert_contains "$sys_zsh" '# fake aenv completion for zsh' -assert_rc_clean "$zshrc" # system mode must NOT edit the user rc - -echo "==> system-mode uninstall" -HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" -assert_absent "$sys_bash" -assert_absent "$sys_fish" -assert_absent "$sys_zsh" - -# --------------------------------------------------------------------------- -# Test 3: auto-detection from a prefix under $HOME behaves like user mode -# --------------------------------------------------------------------------- -echo "==> prefix-under-HOME selects user mode" -HOME="$fake_home" bash "$helper" install --prefix="$fake_home/.local" -assert_contains "$fake_home/.local/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' -# shellcheck disable=SC2016 # searching for a literal $(...) string in the rc -assert_contains "$zshrc" 'eval "$(aenv completion zsh)"' # rc-snippet, not a static file -assert_absent "$fake_home/.local/share/zsh/site-functions/_aenv" # no static file in user mode -HOME="$fake_home" bash "$helper" uninstall --prefix="$fake_home/.local" -assert_absent "$fake_home/.local/share/bash-completion/completions/aenv" -assert_rc_clean "$zshrc" - -# --------------------------------------------------------------------------- -# Test 4: uninstall is a no-op when nothing is installed (and never fails) -# --------------------------------------------------------------------------- -echo "==> uninstall on a clean tree is a no-op" -HOME="$fake_home" bash "$helper" uninstall --user -HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" - -# --------------------------------------------------------------------------- -# Test 5: every malformed marker layout is left byte-for-byte untouched by both -# install and uninstall (orphan start/end, reversed, nested). Install must not -# append onto a malformed state; uninstall must not truncate it. -# --------------------------------------------------------------------------- -echo "==> malformed marker layouts are untouched by install and uninstall" -home_mal="$tmp_root/home-mal"; mkdir -p "$home_mal" -layouts=( - 'orphan-start|user-before\n# >>> aenv completion >>>\nuser-after\n' - 'orphan-end|user-before\n# <<< aenv completion <<<\nuser-after\n' - 'reversed|# <<< aenv completion <<<\nuser-mid\n# >>> aenv completion >>>\n' - 'nested|# >>> aenv completion >>>\n# >>> aenv completion >>>\nx\n# <<< aenv completion <<<\n# <<< aenv completion <<<\n' -) -for entry in "${layouts[@]}"; do - name="${entry%%|*}"; body="${entry#*|}" - rc="$home_mal/.zshrc" - printf '%b' "$body" > "$rc" - cp "$rc" "$rc.orig" - HOME="$home_mal" bash "$helper" install --user 2>/dev/null - cmp -s "$rc" "$rc.orig" || fail "install mutated malformed ($name) rc" - HOME="$home_mal" bash "$helper" uninstall --user 2>/dev/null - cmp -s "$rc" "$rc.orig" || fail "uninstall mutated malformed ($name) rc" - rm -f "$rc" "$rc.orig" -done - -# --------------------------------------------------------------------------- -# Test 6: system mode skips the static zsh file when aenv is not on PATH but -# still writes the bash/fish stubs (graceful degradation, non-fatal). -# --------------------------------------------------------------------------- -echo "==> system mode without aenv on PATH skips only the static zsh file" -# Hermetic PATH: only the utilities the helper needs, no aenv anywhere. -HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null -assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' -assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'aenv completion fish | source' -assert_absent "$sys_prefix/share/zsh/site-functions/_aenv" -HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" uninstall --prefix="$sys_prefix" -assert_absent "$sys_prefix/share/bash-completion/completions/aenv" -assert_absent "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" - -# --------------------------------------------------------------------------- -# Test 7: a missing $HOME must not abort the helper (set -u) and must not make -# an absolute system prefix match the "$prefix" == "$HOME"/* glob. Regression -# guard for the empty-HOME mode-detection bug. -# --------------------------------------------------------------------------- -echo "==> missing HOME with system prefix stays in system mode and does not abort" -env -u HOME bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 \ - || fail "helper aborted under set -u when HOME is unset" -assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'source <(aenv completion bash)' -env -u HOME bash "$helper" uninstall --prefix="$sys_prefix" >/dev/null 2>&1 -assert_absent "$sys_prefix/share/bash-completion/completions/aenv" - -# --------------------------------------------------------------------------- -# Test 8: a failing `aenv completion zsh` must not truncate an existing valid -# static file (atomic temp+rename), and must not leave temp files behind. -# --------------------------------------------------------------------------- -echo "==> failing aenv completion zsh leaves the existing managed _aenv intact" -mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" -invoked="$tmp_root/failing-aenv-invoked" -# Seed a managed _aenv with a working aenv first, so the install-side ownership -# guard permits a later re-install attempt; then swap to a failing aenv. -# shellcheck disable=SC2016 # ${1:-} is literal stub text -printf '#!/usr/bin/env bash\ncase "${1:-}" in completion) echo "#compdef aenv"; echo "echo body";; esac\n' > "$sys_prefix/bin/aenv" -chmod +x "$sys_prefix/bin/aenv" -HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 -assert_contains "$sys_prefix/share/zsh/site-functions/_aenv" '#compdef aenv' -cp "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv.orig" -printf '#!/usr/bin/env bash\nprintf x > "%s"\nexit 1\n' "$invoked" > "$sys_prefix/bin/aenv" -chmod +x "$sys_prefix/bin/aenv" -rm -f "$invoked" -HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ - || fail "helper aborted when aenv completion zsh exits nonzero" -[[ -f "$invoked" ]] || fail "prefix-local aenv stub was not invoked on re-install" -cmp -s "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv.orig" \ - || fail "failed generation modified the existing _aenv" -leftovers=( "$sys_prefix/share/zsh/site-functions"/* ) -[[ "${#leftovers[@]}" -eq 1 ]] || fail "expected no temp leftovers, found: ${leftovers[*]}" -rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share" - -# --------------------------------------------------------------------------- -# Test 8e: install must NOT overwrite an existing completion file the installer -# did not create (install-side ownership guard, symmetric with uninstall). -# --------------------------------------------------------------------------- -echo "==> install leaves an existing non-aenv-owned completion file untouched" -mkdir -p "$sys_prefix/share/bash-completion/completions" "$sys_prefix/share/fish/vendor_completions.d" -printf '# my hand-written bash completion\n' > "$sys_prefix/share/bash-completion/completions/aenv" -printf '# my fish completion\n' > "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" -HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null -assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'my hand-written' -assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'my fish completion' -rm -rf "${sys_prefix:?}/share" - -# --------------------------------------------------------------------------- -# Test 8b: a zero-exit-but-empty `aenv completion zsh` must NOT replace a valid -# existing _aenv (the installer rejects an empty generated temp before rename). -# --------------------------------------------------------------------------- -echo "==> empty aenv completion zsh output leaves the existing managed _aenv intact" -mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" -# Seed managed _aenv (working aenv), then swap to an aenv that exits 0 with no bytes. -# shellcheck disable=SC2016 # ${1:-} is literal stub text -printf '#!/usr/bin/env bash\ncase "${1:-}" in completion) echo "#compdef aenv"; echo "echo body";; esac\n' > "$sys_prefix/bin/aenv" -chmod +x "$sys_prefix/bin/aenv" -HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 -cp "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv8b.orig" -printf '#!/usr/bin/env bash\nexit 0\n' > "$sys_prefix/bin/aenv" -chmod +x "$sys_prefix/bin/aenv" -HOME="$fake_home" PATH="$hermetic_bin" bash "$helper" install --prefix="$sys_prefix" 2>/dev/null \ - || fail "helper aborted on empty completion output" -cmp -s "$sys_prefix/share/zsh/site-functions/_aenv" "$tmp_root/_aenv8b.orig" \ - || fail "empty output replaced a valid _aenv" -rm -rf "${sys_prefix:?}/bin" "${sys_prefix:?}/share" - -# --------------------------------------------------------------------------- -# Test 8c: the static zsh file keeps #compdef on line 1 (the ownership marker -# is appended, not prepended, so zsh still loads the function) and carries the -# ownership marker so a later uninstall can recognize it. -# --------------------------------------------------------------------------- -echo "==> static zsh keeps #compdef first-line and carries the ownership marker" -mkdir -p "$sys_prefix/bin" "$sys_prefix/share/zsh/site-functions" -# shellcheck disable=SC2016 # ${1:-} is literal text for the stub script, not this shell -printf '#!/usr/bin/env bash\ncase "${1:-}" in completion) echo "#compdef aenv"; echo "echo body";; esac\n' > "$sys_prefix/bin/aenv" -chmod +x "$sys_prefix/bin/aenv" -HOME="$fake_home" bash "$helper" install --prefix="$sys_prefix" >/dev/null 2>&1 -[[ "$(head -1 "$sys_prefix/share/zsh/site-functions/_aenv")" == "#compdef aenv" ]] \ - || fail "static zsh #compdef must be the first line" -grep -qF -- "# managed by aenv-installer" "$sys_prefix/share/zsh/site-functions/_aenv" \ - || fail "static zsh must carry the ownership marker" -HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" >/dev/null 2>&1 -assert_absent "$sys_prefix/share/zsh/site-functions/_aenv" -rm -rf "${sys_prefix:?}/bin" - -# --------------------------------------------------------------------------- -# Test 8d: uninstall will NOT delete a completion file the installer did not -# create (no ownership marker) — a hand-maintained or package-manager file at -# the conventional path is left untouched with a warning. -# --------------------------------------------------------------------------- -echo "==> uninstall leaves a non-aenv-owned completion file untouched" -mkdir -p "$sys_prefix/share/bash-completion/completions" "$sys_prefix/share/fish/vendor_completions.d" -printf '# my hand-written aenv completion\n' > "$sys_prefix/share/bash-completion/completions/aenv" -printf '# my fish completion\n' > "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" -HOME="$fake_home" bash "$helper" uninstall --prefix="$sys_prefix" 2>/dev/null -assert_contains "$sys_prefix/share/bash-completion/completions/aenv" 'my hand-written' -assert_contains "$sys_prefix/share/fish/vendor_completions.d/aenv.fish" 'my fish completion' -rm -rf "${sys_prefix:?}/share" - - -echo "==> unrelated rc content survives install and uninstall" -home_surround="$tmp_root/home-surround" -mkdir -p "$home_surround" -zsrc="$home_surround/.zshrc" -printf 'alias-before=1\n' > "$zsrc" -HOME="$home_surround" bash "$helper" install --user -printf 'alias-after=2\n' >> "$zsrc" -HOME="$home_surround" bash "$helper" uninstall --user -assert_contains "$zsrc" 'alias-before=1' -assert_contains "$zsrc" 'alias-after=2' -assert_rc_clean "$zsrc" - -# --------------------------------------------------------------------------- -# Test 10: user mode requested with HOME unset warns and skips (no abort under -# set -u); closes the non-fatal contract for the user-mode destination paths. -# --------------------------------------------------------------------------- -echo "==> user mode with unset HOME warns and skips without aborting" -home_unset_out="$tmp_root/home-unset.out" -env -u HOME bash "$helper" install --user >"$home_unset_out" 2>&1 \ - || fail "helper aborted during install --user with HOME unset" -grep -q 'HOME is unset' "$home_unset_out" || fail "expected a HOME-unset warning during install" -env -u HOME bash "$helper" uninstall --user >"$home_unset_out" 2>&1 \ - || fail "helper aborted during uninstall --user with HOME unset" -grep -q 'HOME is unset' "$home_unset_out" || fail "expected a HOME-unset warning during uninstall" - -echo "==> all shell-completion checks passed" From af6c267d6efbcef370f9b9cb5a87d0f748f6062b Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Mon, 3 Aug 2026 21:42:14 +0200 Subject: [PATCH 22/23] test(cli): cover completion flush errors --- crates/aenv/src/commands/completion.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/aenv/src/commands/completion.rs b/crates/aenv/src/commands/completion.rs index 2278c89e..28648255 100644 --- a/crates/aenv/src/commands/completion.rs +++ b/crates/aenv/src/commands/completion.rs @@ -187,6 +187,16 @@ mod tests { .expect("BrokenPipe during completion output should not error"); } + #[test] + fn broken_pipe_on_flush_is_treated_as_success() { + let mut out = FailingWriter { + kind: std::io::ErrorKind::BrokenPipe, + fail_on_flush: true, + }; + write_completion(Shell::Bash, &mut out) + .expect("BrokenPipe during completion flush should not error"); + } + #[test] fn other_io_error_propagates() { let mut out = FailingWriter { @@ -201,4 +211,19 @@ mod tests { "error should carry completion context; got: {err}" ); } + + #[test] + fn other_io_error_on_flush_propagates() { + let mut out = FailingWriter { + kind: std::io::ErrorKind::Other, + fail_on_flush: true, + }; + let err = + write_completion(Shell::Bash, &mut out).expect_err("flush errors should propagate"); + assert!( + err.to_string() + .contains("writing completion script to stdout"), + "error should carry completion context; got: {err}" + ); + } } From a13c510bdab4a95d873c8b6cb27e851d10a6b2f2 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Wed, 5 Aug 2026 19:30:33 +0200 Subject: [PATCH 23/23] docs(cli): clarify manual completion activation --- docs/src/getting-started/aenv-cli.md | 48 ++++++++++++++++++---------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/src/getting-started/aenv-cli.md b/docs/src/getting-started/aenv-cli.md index 76ba795a..49eab61d 100644 --- a/docs/src/getting-started/aenv-cli.md +++ b/docs/src/getting-started/aenv-cli.md @@ -281,38 +281,54 @@ To delete a snapshot, use `aenv template delete ` or `aenv template `aenv completion ` prints a shell-completion script for the `aenv` CLI to stdout. -### Generate a script +### Generate and install a script + +#### Bash ```bash -aenv completion bash -aenv completion zsh -aenv completion fish +mkdir -p ~/.local/share/bash-completion/completions +aenv completion bash > ~/.local/share/bash-completion/completions/aenv ``` -Each command writes the matching script to stdout, so redirect it into the standard per-user completion directory for your shell: +The Bash completion file is loaded on demand by +[`bash-completion`](https://github.com/scop/bash-completion). +This requires `bash-completion` to be installed and initialized in the current +shell. -```bash -aenv completion bash > ~/.local/share/bash-completion/completions/aenv -aenv completion zsh > ~/.local/share/zsh/site-functions/_aenv -aenv completion fish > ~/.config/fish/completions/aenv.fish +#### Zsh + +```zsh +mkdir -p ~/.local/share/zsh/site-functions +aenv completion zsh > ~/.local/share/zsh/site-functions/_aenv ``` -bash and fish auto-load completions from those directories. zsh autoloads `_cmdname` functions from directories on `fpath`; `~/.local/share/zsh/site-functions` is not on `fpath` by default, so if completion does not load, add the directory to `fpath` before `compinit`: +Zsh loads completion functions from directories listed in `fpath`. +`~/.local/share/zsh/site-functions` is not included in `fpath` by default on +all systems. Add the following lines to `~/.zshrc` before any existing +`compinit` invocation: -```bash +```zsh fpath=(~/.local/share/zsh/site-functions $fpath) autoload -Uz compinit compinit ``` -### Activate it +#### Fish + +```shell +mkdir -p ~/.config/fish/completions +aenv completion fish > ~/.config/fish/completions/aenv.fish +``` + +### Activate without installing -For a one-session test, evaluate the script in the current shell: +To test completion for the current shell session without saving a generated +file: ```bash -source <(aenv completion bash) # bash -eval "$(aenv completion zsh)" # zsh -aenv completion fish | source # fish +source <(aenv completion bash) # Bash +eval "$(aenv completion zsh)" # Zsh +aenv completion fish | source # Fish ``` Once loaded, completion covers the CLI surface: