From 13a7a3a5ddcf80c39cd5c9ca51535749e000cdb9 Mon Sep 17 00:00:00 2001 From: Raphael Coeffic Date: Sun, 22 Dec 2024 08:57:07 +0100 Subject: [PATCH] feat: command-not-found hook Automatically install packages from the `command_not_found_handler` zsh hook. --- .github/workflows/rust.yml | 6 ++ .gitignore | 9 +- Cargo.lock | 51 +++++---- Cargo.toml | 2 + Makefile | 48 ++++++--- src/bin/pkg.rs | 191 +++++++++++++++++++++++---------- src/etc/zshrc | 5 + tools/convert-program-index.sh | 12 +++ 8 files changed, 231 insertions(+), 93 deletions(-) create mode 100755 tools/convert-program-index.sh diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ea8abb6..8bd460a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -29,6 +29,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Build program index + env: + ARCH: ${{ matrix.platform.arch }} + run: | + make src/assets/programs.csv build_arch=$ARCH + - name: Build pkg tool uses: houseabsolute/actions-rust-cross@v0 with: diff --git a/.gitignore b/.gitignore index 3c6c120..43904e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ /target -/base -/ofs -nix -image_builder/base.tar.xz -base.tar.xz -base.sha256 +/nix +base.* +src/assets/programs.* diff --git a/Cargo.lock b/Cargo.lock index 645a7e3..02d3146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,15 +197,15 @@ checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "console" -version = "0.15.8" +version = "0.15.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" dependencies = [ "encode_unicode", - "lazy_static", "libc", - "unicode-width 0.1.14", - "windows-sys 0.52.0", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", ] [[package]] @@ -252,6 +252,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "digest" version = "0.10.7" @@ -302,6 +323,8 @@ dependencies = [ "anyhow", "base64", "clap", + "console", + "csv", "dirs", "env_logger", "exitcode", @@ -333,9 +356,9 @@ checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "env_filter" @@ -643,7 +666,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width 0.2.0", + "unicode-width", "web-time", ] @@ -692,12 +715,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.164" @@ -1204,12 +1221,6 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 2ac5763..23ed8e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ anstream = { version = "0.6.18", default-features = false, features = ["auto"] } anyhow = "1.0.93" base64 = "0.22.1" clap = { version = "4.5.21", features = ["derive", "env"] } +console = "0.15.10" +csv = "1.3.1" dirs = "5.0" env_logger = "0.11.5" exitcode = "1.1.2" diff --git a/Makefile b/Makefile index c204cec..e15965d 100644 --- a/Makefile +++ b/Makefile @@ -51,28 +51,52 @@ endif cargo_build = cargo build --profile $(cargo_profile) --target $(cargo_target) -.PHONY: clean dist-clean base-image pkg-bin $(pkg_bin) +# programs index +nix_channel ?= unstable +nix_channels_url = https://nixos.org/channels/nixos-$(nix_channel) +dl_prog_index = curl -sL -o - $(nix_channels_url)/nixexprs.tar.xz + +assets = src/assets +prog_index_sqlite = $(assets)/programs.sqlite +unpack_prog_index = tar xJ -C $(assets) --strip-components=1 --wildcards '*/programs.sqlite' + +prog_index_csv = src/assets/programs.csv + +.PHONY: clean dist-clean clean-assets base-image pkg-bin $(pkg_bin) base_files = base.sha256 base.tar base.tar.xz clean: - @echo "Removing base files" + @echo "* Removing base files" @rm -f $(base_files) - @echo "Removing nix directory" + @echo "* Removing nix directory" @chmod -R +w nix/* 2>/dev/null ; rm -rf ./nix -dist-clean: clean - @echo "Removing rust builds" +dist-clean: clean clean-assets + @echo "* Removing rust builds" @rm -rf target +clean-assets: + @echo "* Removing assets" + @rm -rf $(assets) + base-image: pkg-bin - @echo "Building base image" - $(build_img) $(build_img_args) + @echo "* Building base image" + @$(build_img) $(build_img_args) pkg-bin: $(pkg_bin) -$(pkg_bin): - @echo "Building pkg tool" - $(cargo_build) --bin pkg - @echo "Stripping debug info" - strip $@ +$(pkg_bin): $(prog_index_csv) + @echo "* Building pkg tool" + @$(cargo_build) --bin pkg + @echo "* Stripping debug info" + @strip $@ + +$(prog_index_sqlite): + @echo "* Downloading $@" + @mkdir -p $(assets) + @$(dl_prog_index) | $(unpack_prog_index) + +$(prog_index_csv): $(prog_index_sqlite) + @echo "* Converting $< to $@" + @./tools/convert-program-index.sh $(prog_index_sqlite) $(build_arch) > $@ diff --git a/src/bin/pkg.rs b/src/bin/pkg.rs index 3e25690..a6231f3 100644 --- a/src/bin/pkg.rs +++ b/src/bin/pkg.rs @@ -1,4 +1,8 @@ -use std::process; +use std::{ + io::{self, IsTerminal, Write}, + os::unix::process::CommandExt, + process::{self, Command}, +}; use anstream::{eprintln, println}; use anyhow::{Context, Result}; @@ -25,6 +29,16 @@ enum Commands { Install { name: String }, /// Remove a package Remove { name: String }, + #[command(name = "_CmdNotFound", hide = true, disable_help_flag = true)] + _CmdNotFound { + cmd: String, + #[arg( + trailing_var_arg = true, + allow_hyphen_values = true, + hide = true + )] + args: Vec, + }, } fn init_logging() { @@ -41,69 +55,136 @@ fn main() -> Result<()> { init_logging(); match &cli.command { - Commands::List => { - for pkg in dive::nixpkgs::all_packages_sorted()? { - println!("{} ({})", pkg.name.bold(), pkg.version.dimmed()); + Commands::List => list_packages()?, + Commands::Search { keywords } => search_packages(keywords)?, + Commands::Install { name } => install_package(name)?, + Commands::Remove { name } => remove_package(name)?, + Commands::_CmdNotFound { cmd, args } => command_not_found(cmd, args)?, + } + + Ok(()) +} + +fn list_packages() -> Result<()> { + for pkg in dive::nixpkgs::all_packages_sorted()? { + println!("{} ({})", pkg.name.bold(), pkg.version.dimmed()); + } + Ok(()) +} + +fn search_packages(keywords: &Vec) -> Result<()> { + let matches = dive::nixpkgs::query(keywords)?; + if matches.is_empty() { + println!("{}", "No matches".bold()); + } else { + for pkg in matches { + println!("* {} ({})", pkg.name.bold(), pkg.version.dimmed()); + if let Some(description) = pkg.description { + println!(" {}", description); } + println!(); } - Commands::Search { keywords } => { - let matches = dive::nixpkgs::query(keywords)?; - if matches.is_empty() { - println!("{}", "No matches".bold()); - } else { - for pkg in matches { - println!( - "* {} ({})", - pkg.name.bold(), - pkg.version.dimmed() - ); - if let Some(description) = pkg.description { - println!(" {}", description); - } - println!(); - } - } + } + Ok(()) +} + +fn install_package(name: &str) -> Result<()> { + let pkgs = dive::nixpkgs::all_packages_sorted()?; + if pkgs.iter().any(|p| p.name == name) { + eprintln!("error: '{}' is already installed", name); + process::exit(1); + } + match dive::nixpkgs::find_package(name)? { + None => { + eprintln!("error: '{}' does not exist", name); + process::exit(1); } - Commands::Install { name } => { - let pkgs = dive::nixpkgs::all_packages_sorted()?; - if pkgs.iter().any(|p| &p.name == name) { - eprintln!("error: '{}' is already installed", name); + Some(pkg) => { + if let Err(err) = dive::nixpkgs::install_package(pkg) + .context("failed to install package") + { + eprintln!("error: {err}"); process::exit(1); } - match dive::nixpkgs::find_package(name)? { - None => { - eprintln!("error: '{}' does not exist", name); - process::exit(1); - } - Some(pkg) => { - if let Err(err) = dive::nixpkgs::install_package(pkg) - .context("failed to install package") - { - eprintln!("error: {err}"); - process::exit(1); - } - } - } + Ok(()) } - Commands::Remove { name } => { - let pkgs = dive::nixpkgs::all_packages_sorted()?; - let maybe_pkg = pkgs.iter().find(|p| &p.name == name); - if maybe_pkg.is_none() { - eprintln!("error: '{}' is not installed", name); - process::exit(1); + } +} + +fn remove_package(name: &str) -> Result<()> { + let pkgs = dive::nixpkgs::all_packages_sorted()?; + let maybe_pkg = pkgs.iter().find(|p| p.name == name); + if maybe_pkg.is_none() { + eprintln!("error: '{}' is not installed", name); + process::exit(1); + } + let pkg = maybe_pkg.unwrap(); + if pkg.is_builtin() { + eprintln!( + "Error: '{}' is a built-in package and cannot be removed", + name + ); + process::exit(1); + } + dive::nixpkgs::remove_package(name).context("failed to remove package") +} + +fn find_program(name: &str) -> Result> { + let programs = include_bytes!("../assets/programs.csv"); + let mut rdr = csv::Reader::from_reader(programs.as_slice()); + let mut record = csv::StringRecord::new(); + while rdr.read_record(&mut record)? { + debug_assert!(record.len() == 2, "CSV index corrupted"); + match record[0].cmp(name) { + std::cmp::Ordering::Equal => { + let pkg_name = &record[1]; + return Ok(Some(pkg_name.to_owned())); } - let pkg = maybe_pkg.unwrap(); - if pkg.is_builtin() { - eprintln!( - "Error: '{}' is a built-in package and cannot be removed", - name - ); + std::cmp::Ordering::Greater => break, + _ => continue, + } + } + Ok(None) +} + +fn command_not_found(cmd: &str, args: &Vec) -> Result<()> { + if !io::stdin().is_terminal() { + process::exit(1); + } + let pkg_name = match find_program(cmd) { + Result::Err(err) => { + eprintln!("Error: {err}"); + process::exit(1); + } + Result::Ok(prog_match) => match prog_match { + None => { + log::debug!("No match"); process::exit(1); } - return dive::nixpkgs::remove_package(name) - .context("failed to remove package"); + Some(pkg_name) => pkg_name, + }, + }; + print!( + "* {} does not exist. Install {} package? [y/N] ", + cmd, + pkg_name.bold() + ); + let _ = io::stdout().flush(); + let confirmed = match console::Term::stdout().read_key() { + Result::Err(err) => { + log::debug!("Error: {err}"); + process::exit(1); } + Result::Ok(key) => match key { + console::Key::Char(c) => c == 'Y' || c == 'y', + _ => false, + }, + }; + println!(); + if confirmed { + install_package(&pkg_name)?; + let err = Command::new(cmd).args(args).exec(); + eprintln!("Error: failed to exec {cmd}: {err}"); } - - Ok(()) + process::exit(1); } diff --git a/src/etc/zshrc b/src/etc/zshrc index 79768d8..92f9756 100644 --- a/src/etc/zshrc +++ b/src/etc/zshrc @@ -43,6 +43,7 @@ fpath+=( $PRETZO_MODULES/completion ) unsetopt EXTENDED_GLOB unsetopt MENU_COMPLETE +unsetopt CORRECT setopt AUTO_CD setopt AUTO_MENU @@ -56,6 +57,10 @@ HISTSIZE=10000 # in memory SAVEHIST=10000 # persistent bindkey -e # emacs keymap +command_not_found_handler() { + pkg _CmdNotFound $* +} + # help stuff # echo "Welcome to the dive shell!" diff --git a/tools/convert-program-index.sh b/tools/convert-program-index.sh new file mode 100755 index 0000000..c9b9f07 --- /dev/null +++ b/tools/convert-program-index.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +excluded_packages="'busybox','fltk','fltk14'" + +sql_filter="system = '$2-linux'" +sql_filter+=" AND package NOT IN ($excluded_packages)" +sql_filter+=" AND name NOT LIKE 'nix%'" + +sqlite3 -csv "$1" "SELECT name, package FROM Programs +WHERE $sql_filter +GROUP by name, package +ORDER by name, package;"