From 66b66d7dedcecb7960d9c7c8f47b0f2a6e2639c9 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:46:16 -0500 Subject: [PATCH] feat(DEN-1420): expose asdf through the canonical zed env CLI (#143) Expose the existing read-only asdf adapter through typed `zed env import asdf` and `zed env verify asdf` routes, with main-binary tests, documentation, completion coverage, and a permanent three-platform certification workflow. --- .github/workflows/asdf-interop.yml | 15 ++++- README.md | 2 + docs/asdf-interop.md | 9 +++ src/cli.rs | 42 +++++++----- src/main.rs | 51 ++++++++++---- tests/asdf_main_cli.rs | 104 +++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 29 deletions(-) create mode 100644 tests/asdf_main_cli.rs diff --git a/.github/workflows/asdf-interop.yml b/.github/workflows/asdf-interop.yml index f5893ba3..d3916cf4 100644 --- a/.github/workflows/asdf-interop.yml +++ b/.github/workflows/asdf-interop.yml @@ -5,9 +5,13 @@ on: paths: - "src/asdf_environment.rs" - "src/bin/zed-asdf.rs" + - "src/cli.rs" + - "src/main.rs" - "src/lib.rs" - "tests/asdf_environment_cli.rs" + - "tests/asdf_main_cli.rs" - "docs/asdf-interop.md" + - "README.md" - ".github/workflows/asdf-interop.yml" - "Cargo.toml" - "Cargo.lock" @@ -16,8 +20,13 @@ on: paths: - "src/asdf_environment.rs" - "src/bin/zed-asdf.rs" + - "src/cli.rs" + - "src/main.rs" - "src/lib.rs" - "tests/asdf_environment_cli.rs" + - "tests/asdf_main_cli.rs" + - "docs/asdf-interop.md" + - "README.md" - ".github/workflows/asdf-interop.yml" - "Cargo.toml" - "Cargo.lock" @@ -70,8 +79,12 @@ jobs: set -euo pipefail cargo test --locked --lib asdf_environment::tests cargo test --locked --test asdf_environment_cli + cargo test --locked --test asdf_main_cli - name: Clippy (strict) run: | set -euo pipefail - cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings \ No newline at end of file + cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings + + - name: Prove import and verification did not mutate the checkout + run: git diff --exit-code diff --git a/README.md b/README.md index 8d55bf62..179b3f9e 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ registry hosts both on S3/Cloudflare R2. | `zed uninstall [/ ...]` (`zed un`) | Transactionally remove all or selected materialized packages while retaining the manifest and lockfile for a frozen reinstall | | `zed env import mise [--config PATH] [--lock PATH] [--frozen] [--json]` | Import the supported project-local mise tool/lock subset as the shared normalized `EnvironmentPlan`; never loads parent/global config or executes hooks | | `zed env verify mise [--config PATH] [--lock PATH] --frozen [--json]` | Fail closed on missing lock coverage, drift, malformed checksums, unsupported semantics, or non-portable frozen state and report the stable plan digest | +| `zed env import asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] [--frozen] [--json]` | Import project-local asdf selections and optional immutable plugin/artifact provenance without invoking asdf or plugin code | +| `zed env verify asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] --frozen [--json]` | Verify exact asdf tool, plugin revision, artifact SHA-256, platform, and normalized plan identity without reading parent/global configuration | | `zed find ` | Search the registry | | `zed pack` | Build the pruned, deterministic `tar.gz` artifact | | `zed release plan [--json]` | Print the credential-free Zed, native-registry, and forge-package release set derived from `.zpkg.toml` | diff --git a/docs/asdf-interop.md b/docs/asdf-interop.md index 56833542..2aaa2663 100644 --- a/docs/asdf-interop.md +++ b/docs/asdf-interop.md @@ -5,6 +5,15 @@ separate from the main `zed env` dispatcher until the shared mise command branch lands; the reusable implementation lives in `zed_cli::asdf_environment` so the later wiring step does not need to reimplement parsing or validation. +## Canonical `zed env` commands + +```console +zed env import asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] [--frozen] [--json] +zed env verify asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] --frozen [--json] +``` + +The staged `zed-asdf` executable remains a focused adapter-test surface, but the supported user interface is the typed `zed env` dispatcher. Both routes call the same Rust module and preserve the same read-only, project-local, no-execution contract. + ## Commands ```text diff --git a/src/cli.rs b/src/cli.rs index 0bbc4401..c81e00c3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -164,6 +164,8 @@ impl From for clap_complete::Shell { pub enum EnvironmentManagerArg { /// Import or verify project-local mise configuration. Mise, + /// Import or verify project-local asdf configuration and Zed-owned provenance. + Asdf, } #[derive(Debug, Subcommand)] @@ -663,21 +665,31 @@ mod tests { #[test] fn environment_import_and_verify_are_typed() { - for action in ["import", "verify"] { - let cli = Cli::try_parse_from([ - "zed", - "env", - action, - "mise", - "--config", - "mise.toml", - "--lock", - "mise.lock", - "--frozen", - "--json", - ]) - .unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); + for manager in ["mise", "asdf"] { + for action in ["import", "verify"] { + let cli = Cli::try_parse_from([ + "zed", + "env", + action, + manager, + "--config", + if manager == "mise" { + "mise.toml" + } else { + ".tool-versions" + }, + "--lock", + if manager == "mise" { + "mise.lock" + } else { + ".zed/asdf.lock.toml" + }, + "--frozen", + "--json", + ]) + .unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + } } } diff --git a/src/main.rs b/src/main.rs index c29d7f29..71cd6454 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ +use zed_cli::asdf_environment; use zed_cli::auth; use zed_cli::cli::EnvCmd; -use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, ReleaseCmd, StoreCmd}; +use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, +}; use zed_cli::completion; use zed_cli::config::Config; use zed_cli::dev; @@ -132,27 +135,49 @@ fn run(cli: Cli) -> anyhow::Result<()> { Cmd::Uninstall { specs } => ops::uninstall(&cwd, &cfg, &specs), Cmd::Env { cmd } => match cmd { EnvCmd::Import { - manager: _, + manager, config, lock, frozen, json, - } => { - let imported = - environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; - environment::print_import(&imported, json) - } + } => match manager { + EnvironmentManagerArg::Mise => { + let imported = + environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; + environment::print_import(&imported, json) + } + EnvironmentManagerArg::Asdf => { + let imported = asdf_environment::import_asdf( + &cwd, + config.as_deref(), + lock.as_deref(), + frozen, + )?; + asdf_environment::print_import(&imported, json) + } + }, EnvCmd::Verify { - manager: _, + manager, config, lock, frozen, json, - } => { - let imported = - environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; - environment::print_verification(&imported, json) - } + } => match manager { + EnvironmentManagerArg::Mise => { + let imported = + environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; + environment::print_verification(&imported, json) + } + EnvironmentManagerArg::Asdf => { + let imported = asdf_environment::import_asdf( + &cwd, + config.as_deref(), + lock.as_deref(), + frozen, + )?; + asdf_environment::print_verification(&imported, json) + } + }, }, Cmd::Completions { shell } => { completion::print(shell.into()); diff --git a/tests/asdf_main_cli.rs b/tests/asdf_main_cli.rs new file mode 100644 index 00000000..38da7b03 --- /dev/null +++ b/tests/asdf_main_cli.rs @@ -0,0 +1,104 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed")) +} + +fn checksum(digit: char) -> String { + digit.to_string().repeat(64) +} + +fn revision(digit: char) -> String { + digit.to_string().repeat(40) +} + +fn write_fixture(root: &Path) { + fs::write( + root.join(".tool-versions"), + "nodejs 22.11.0\npython 3.12.4\n", + ) + .unwrap(); + fs::create_dir_all(root.join(".zed")).unwrap(); + fs::write( + root.join(".zed/asdf.lock.toml"), + format!( + r#"schema = 1 + +[plugins.nodejs] +version = "22.11.0" +url = "https://github.com/asdf-vm/asdf-nodejs.git" +revision = "{}" +sha256 = "{}" +platforms = ["x86_64-linux"] + +[plugins.python] +version = "3.12.4" +url = "https://github.com/danhper/asdf-python.git" +revision = "{}" +sha256 = "{}" +"#, + revision('1'), + checksum('a'), + revision('2'), + checksum('b'), + ), + ) + .unwrap(); +} + +#[test] +fn canonical_env_dispatch_imports_and_verifies_asdf_read_only() { + let temp = tempfile::tempdir().unwrap(); + write_fixture(temp.path()); + let config_before = fs::read(temp.path().join(".tool-versions")).unwrap(); + let lock_before = fs::read(temp.path().join(".zed/asdf.lock.toml")).unwrap(); + + for action in ["import", "verify"] { + let output = Command::new(binary()) + .current_dir(temp.path()) + .env("PATH", "") + .args(["env", action, "asdf", "--frozen", "--json"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{action} stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + if action == "verify" { + assert_eq!(value["manager"], "asdf"); + assert_eq!(value["verified"], true); + } else { + assert_eq!(value["tools"].as_object().unwrap().len(), 2); + } + } + + assert_eq!( + fs::read(temp.path().join(".tool-versions")).unwrap(), + config_before + ); + assert_eq!( + fs::read(temp.path().join(".zed/asdf.lock.toml")).unwrap(), + lock_before + ); +} + +#[test] +fn canonical_env_dispatch_fails_closed_without_frozen_provenance() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".tool-versions"), "nodejs 22.11.0\n").unwrap(); + let output = Command::new(binary()) + .current_dir(temp.path()) + .env("PATH", "") + .args(["env", "verify", "asdf", "--frozen"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("frozen asdf import requires `.zed/asdf.lock.toml`") + ); +}