From a1d0ffed3cc7ed66274f69d1b85ad35540e3fb2c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 08:09:03 -0500 Subject: [PATCH 01/12] feat(DEN-1467): land project-local asdf environment import Reconstruct the reviewed asdf adapter directly on current main and preserve the newly landed mise.lock module in the library surface rather than choosing either side of the prior overlap. --- .github/workflows/asdf-interop.yml | 77 +++ docs/asdf-interop.md | 64 +++ src/asdf_environment.rs | 722 +++++++++++++++++++++++++++++ src/bin/zed-asdf.rs | 70 +++ src/lib.rs | 1 + tests/asdf_environment_cli.rs | 155 +++++++ 6 files changed, 1089 insertions(+) create mode 100644 .github/workflows/asdf-interop.yml create mode 100644 docs/asdf-interop.md create mode 100644 src/asdf_environment.rs create mode 100644 src/bin/zed-asdf.rs create mode 100644 tests/asdf_environment_cli.rs diff --git a/.github/workflows/asdf-interop.yml b/.github/workflows/asdf-interop.yml new file mode 100644 index 00000000..85372420 --- /dev/null +++ b/.github/workflows/asdf-interop.yml @@ -0,0 +1,77 @@ +name: asdf environment interop + +on: + pull_request: + paths: + - "src/asdf_environment.rs" + - "src/bin/zed-asdf.rs" + - "src/lib.rs" + - "tests/asdf_environment_cli.rs" + - "docs/asdf-interop.md" + - ".github/workflows/asdf-interop.yml" + - "Cargo.toml" + - "Cargo.lock" + push: + branches: [main] + paths: + - "src/asdf_environment.rs" + - "src/bin/zed-asdf.rs" + - "src/lib.rs" + - "tests/asdf_environment_cli.rs" + - ".github/workflows/asdf-interop.yml" + - "Cargo.toml" + - "Cargo.lock" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: asdf-interop-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + shell: bash + +jobs: + asdf-adapter: + name: asdf adapter / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - macos-15 + - windows-2025 + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Format + run: cargo fmt --all --check + + - name: Unit and black-box tests + run: | + set -euo pipefail + cargo test --locked --lib asdf_environment::tests + cargo test --locked --test asdf_environment_cli + + - name: Clippy (strict) + run: | + set -euo pipefail + cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings diff --git a/docs/asdf-interop.md b/docs/asdf-interop.md new file mode 100644 index 00000000..56833542 --- /dev/null +++ b/docs/asdf-interop.md @@ -0,0 +1,64 @@ +# Project-local asdf interoperability + +`zed-asdf` is the executable first slice of the asdf adapter. It is deliberately +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. + +## Commands + +```text +zed-asdf import [--config PATH] [--lock PATH] [--frozen] [--json] +zed-asdf verify [--config PATH] [--lock PATH] [--frozen] [--json] +``` + +The default inputs are the current project's `.tool-versions` and the optional +Zed-owned `.zed/asdf.lock.toml`. The adapter never invokes `asdf`, searches +parents, reads `$HOME`, installs plugins, evaluates plugin scripts, or mutates +either file. + +## Why a sidecar is required for frozen verification + +`.tool-versions` selects tool names and versions, but it does not identify the +plugin repository commit or the downloaded artifact bytes. Frozen portable +verification therefore requires a committed sidecar: + +```toml +schema = 1 + +[plugins.nodejs] +version = "22.11.0" +url = "https://github.com/asdf-vm/asdf-nodejs.git" +revision = "0123456789abcdef0123456789abcdef01234567" +sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +platforms = ["aarch64-darwin", "x86_64-linux"] +``` + +Every configured plugin must have exactly one matching entry, and the sidecar +may not contain extras. The plugin URL must be portable and credential-free, the +revision must be a full immutable 40- or 64-character hexadecimal commit, and +the tool artifact requires a lowercase-normalized SHA-256. Platform identity is +stored separately rather than encoded in SemVer build metadata. + +## Supported initial subset + +- one project-local `.tool-versions`; +- exactly one selected version per plugin; +- exact config/lock tool-name coverage; +- exact version equality between config and sidecar; +- immutable plugin URL and commit provenance; +- SHA-256 tool artifact identity; +- optional platform constraints; +- canonical semantic input and `EnvironmentPlan` digests. + +Multiple asdf fallback versions are rejected because `EnvironmentPlan` v1 has +one resolved identity per tool. `system`, mutable refs, local paths, and other +moving selectors fail shared frozen-portable validation. + +## Isolation and tests + +The adapter is parser-only and read-only. Integration tests launch the real +`zed-asdf` binary with an empty `PATH`, verify that no ambient asdf executable is +needed, snapshot both input files, and prove that parent `.tool-versions` files +are not inherited. The dedicated workflow runs the same contract on Linux, +macOS, and Windows with immutable Action pins. diff --git a/src/asdf_environment.rs b/src/asdf_environment.rs new file mode 100644 index 00000000..edfb204e --- /dev/null +++ b/src/asdf_environment.rs @@ -0,0 +1,722 @@ +//! Project-local asdf interoperability. +//! +//! This adapter reads one `.tool-versions` file and an optional Zed-owned +//! `.zed/asdf.lock.toml` provenance sidecar. It never invokes `asdf`, searches +//! parent directories, reads user-global configuration, installs plugins, or +//! executes plugin code. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zed_interfaces::environment::{ + ActivationPolicy, Checksum, ChecksumAlgorithm, EnvironmentManager, EnvironmentPlan, + EnvironmentSource, EnvironmentValidationMode, ImmutableSource, ToolRequirement, +}; + +const DEFAULT_CONFIG_PATH: &str = ".tool-versions"; +const DEFAULT_LOCK_PATH: &str = ".zed/asdf.lock.toml"; +const ASDF_LOCK_SCHEMA: u32 = 1; + +#[derive(Debug, Clone)] +pub struct ImportedAsdfEnvironment { + pub plan: EnvironmentPlan, + pub config_path: String, + pub lock_path: Option, + pub digest: String, +} + +#[derive(Debug, Clone, Serialize)] +struct ConfiguredTool { + requirement: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct AsdfLockDocument { + schema: u32, + #[serde(default)] + plugins: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct AsdfPluginLock { + version: String, + url: String, + revision: String, + sha256: String, + #[serde(default)] + platforms: Vec, +} + +#[derive(Serialize)] +struct NormalizedAsdfInputs<'a> { + schema: u32, + tools: &'a BTreeMap, + lock: Option<&'a AsdfLockDocument>, +} + +pub fn import_asdf( + cwd: &Path, + config: Option<&Path>, + lock: Option<&Path>, + frozen: bool, +) -> Result { + let root = cwd + .canonicalize() + .with_context(|| format!("failed to resolve project root {}", cwd.display()))?; + + let config_path = match config { + Some(path) => resolve_project_file(&root, path, "asdf config")?, + None => resolve_project_file( + &root, + Path::new(DEFAULT_CONFIG_PATH), + "project-local asdf config", + )?, + }; + let config_relative = project_relative_path(&root, &config_path)?; + let config_text = fs::read_to_string(&config_path) + .with_context(|| format!("failed to read {}", config_path.display()))?; + let configured = parse_tool_versions(&config_text, &config_relative)?; + + let lock_path = resolve_lock_path(&root, lock)?; + if frozen && lock_path.is_none() { + bail!( + "frozen asdf import requires `{DEFAULT_LOCK_PATH}` (or --lock) with exact plugin and artifact provenance" + ); + } + + let (locked, lock_relative) = match lock_path { + Some(path) => { + let relative = project_relative_path(&root, &path)?; + let text = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let parsed = parse_lock_document(&text, &relative)?; + verify_lock_coverage(&configured, &parsed, &relative)?; + (Some(parsed), Some(relative)) + } + None => (None, None), + }; + + let source_digest = digest_manager_inputs(&configured, locked.as_ref())?; + let mut plan = EnvironmentPlan { + activation: ActivationPolicy::FrozenInstall, + ..EnvironmentPlan::default() + }; + + for (name, configured_tool) in &configured { + let plugin = locked + .as_ref() + .and_then(|document| document.plugins.get(name)); + let (resolved, source, checksums, platforms) = match plugin { + Some(plugin) => ( + Some(plugin.version.clone()), + Some(ImmutableSource { + url: plugin.url.clone(), + revision: plugin.revision.clone(), + subdir: None, + checksums: Vec::new(), + }), + vec![Checksum { + algorithm: ChecksumAlgorithm::Sha256, + value: plugin.sha256.clone(), + }], + plugin.platforms.clone(), + ), + None => (None, None, Vec::new(), Vec::new()), + }; + + plan.tools.insert( + name.clone(), + ToolRequirement { + requirement: configured_tool.requirement.clone(), + resolved, + provider: Some("asdf".to_string()), + backend: Some(format!("asdf-plugin:{name}")), + source, + checksums, + platforms, + }, + ); + } + + plan.sources.push(EnvironmentSource { + manager: EnvironmentManager::Asdf, + path: config_relative.clone(), + lock_path: lock_relative.clone(), + digest: Some(source_digest), + }); + + let validation_mode = if frozen { + EnvironmentValidationMode::FrozenPortable + } else { + EnvironmentValidationMode::Authoring + }; + plan.validate(validation_mode) + .context("asdf environment did not satisfy the shared environment contract")?; + + let canonical = plan.canonical_json_bytes()?; + let digest = hex::encode(Sha256::digest(&canonical)); + + Ok(ImportedAsdfEnvironment { + plan: plan.normalized(), + config_path: config_relative, + lock_path: lock_relative, + digest, + }) +} + +pub fn print_import(imported: &ImportedAsdfEnvironment, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&imported.plan)?); + return Ok(()); + } + + println!("manager: asdf"); + println!("config: {}", imported.config_path); + println!( + "lock: {}", + imported + .lock_path + .as_deref() + .unwrap_or("") + ); + println!("tools: {}", imported.plan.tools.len()); + println!("environment-plan-sha256: {}", imported.digest); + Ok(()) +} + +pub fn print_verification(imported: &ImportedAsdfEnvironment, json: bool) -> Result<()> { + if json { + let output = serde_json::json!({ + "manager": "asdf", + "config": imported.config_path, + "lock": imported.lock_path, + "tools": imported.plan.tools.len(), + "environment_plan_sha256": imported.digest, + "verified": true, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!( + "verified asdf environment: {} tool(s), digest {}", + imported.plan.tools.len(), + imported.digest + ); + Ok(()) +} + +fn resolve_lock_path(root: &Path, requested: Option<&Path>) -> Result> { + if let Some(path) = requested { + return resolve_project_file(root, path, "asdf provenance lock").map(Some); + } + + let candidate = root.join(DEFAULT_LOCK_PATH); + if candidate.is_file() { + resolve_project_file(root, &candidate, "asdf provenance lock").map(Some) + } else { + Ok(None) + } +} + +fn resolve_project_file(root: &Path, requested: &Path, kind: &str) -> Result { + let candidate = if requested.is_absolute() { + requested.to_path_buf() + } else { + root.join(requested) + }; + if !candidate.is_file() { + bail!( + "{kind} does not exist or is not a file: {}", + candidate.display() + ); + } + let canonical = candidate + .canonicalize() + .with_context(|| format!("failed to resolve {}", candidate.display()))?; + if !canonical.starts_with(root) { + bail!( + "{kind} escapes the project root: {} resolves outside {}", + candidate.display(), + root.display() + ); + } + Ok(canonical) +} + +fn project_relative_path(root: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(root) + .with_context(|| format!("{} is outside {}", path.display(), root.display()))?; + let mut parts = Vec::new(); + for component in relative.components() { + match component { + Component::Normal(value) => parts.push( + value + .to_str() + .context("asdf paths must be valid UTF-8")? + .to_string(), + ), + _ => bail!("asdf paths must be normalized and project-relative"), + } + } + if parts.is_empty() { + bail!("asdf path must name a project file"); + } + Ok(parts.join("/")) +} + +fn parse_tool_versions(input: &str, path: &str) -> Result> { + let mut tools = BTreeMap::new(); + for (index, original) in input.lines().enumerate() { + let line = original.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + let fields = line.split_whitespace().collect::>(); + if fields.len() != 2 { + bail!( + "`{path}` line {} must contain exactly one plugin and one version; multiple fallback versions cannot be represented losslessly by EnvironmentPlan v1", + index + 1 + ); + } + let name = fields[0]; + let requirement = fields[1]; + validate_tool_name(name, path, index + 1)?; + if requirement.is_empty() { + bail!("`{path}` line {} has an empty version", index + 1); + } + if tools + .insert( + name.to_string(), + ConfiguredTool { + requirement: requirement.to_string(), + }, + ) + .is_some() + { + bail!("duplicate asdf plugin `{name}` in `{path}`"); + } + } + if tools.is_empty() { + bail!("`{path}` contains no asdf tool versions"); + } + Ok(tools) +} + +fn validate_tool_name(name: &str, path: &str, line: usize) -> Result<()> { + if name.is_empty() + || name.starts_with('.') + || name.contains('/') + || name.contains('\\') + || name + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + bail!("invalid asdf plugin name `{name}` in `{path}` line {line}"); + } + Ok(()) +} + +fn parse_lock_document(input: &str, path: &str) -> Result { + let document: AsdfLockDocument = toml::from_str(input) + .with_context(|| format!("failed to parse asdf provenance lock `{path}`"))?; + if document.schema != ASDF_LOCK_SCHEMA { + bail!( + "unsupported asdf provenance schema {} in `{path}`; this build supports {ASDF_LOCK_SCHEMA}", + document.schema + ); + } + if document.plugins.is_empty() { + bail!("asdf provenance lock `{path}` has no [plugins] entries"); + } + + let mut normalized = document; + for (name, plugin) in &mut normalized.plugins { + plugin.version = plugin.version.trim().to_string(); + plugin.url = plugin.url.trim().to_string(); + plugin.revision = plugin.revision.trim().to_ascii_lowercase(); + plugin.sha256 = plugin.sha256.trim().to_ascii_lowercase(); + plugin.platforms = plugin + .platforms + .iter() + .map(|platform| platform.trim().to_string()) + .collect(); + plugin.platforms.sort(); + plugin.platforms.dedup(); + validate_plugin_lock(name, plugin, path)?; + } + Ok(normalized) +} + +fn validate_plugin_lock(name: &str, plugin: &AsdfPluginLock, path: &str) -> Result<()> { + if plugin.version.is_empty() { + bail!("`plugins.{name}.version` in `{path}` must not be empty"); + } + if plugin.url.is_empty() { + bail!("`plugins.{name}.url` in `{path}` must not be empty"); + } + if is_local_reference(&plugin.url) { + bail!( + "`plugins.{name}.url` in `{path}` is non-portable: {}", + plugin.url + ); + } + if https_url_has_userinfo(&plugin.url) { + bail!( + "`plugins.{name}.url` in `{path}` contains user information; commit only credential-free plugin URLs" + ); + } + if !is_full_hex_revision(&plugin.revision) { + bail!( + "`plugins.{name}.revision` in `{path}` must be a full 40- or 64-character hexadecimal commit" + ); + } + if plugin.sha256.len() != 64 || !plugin.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("`plugins.{name}.sha256` in `{path}` must be 64 hexadecimal characters"); + } + for platform in &plugin.platforms { + if platform.is_empty() + || platform + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + bail!("`plugins.{name}.platforms` in `{path}` contains invalid platform `{platform}`"); + } + } + Ok(()) +} + +fn verify_lock_coverage( + configured: &BTreeMap, + locked: &AsdfLockDocument, + path: &str, +) -> Result<()> { + let configured_names = configured.keys().cloned().collect::>(); + let locked_names = locked.plugins.keys().cloned().collect::>(); + let missing = configured_names + .difference(&locked_names) + .cloned() + .collect::>(); + let extra = locked_names + .difference(&configured_names) + .cloned() + .collect::>(); + if !missing.is_empty() || !extra.is_empty() { + bail!( + "asdf config/lock drift in `{path}`: missing locked plugins [{}]; extra locked plugins [{}]", + missing.join(", "), + extra.join(", ") + ); + } + + for (name, configured_tool) in configured { + let plugin = locked + .plugins + .get(name) + .expect("coverage was checked before version comparison"); + if plugin.version != configured_tool.requirement { + bail!( + "asdf version drift for `{name}` in `{path}`: .tool-versions selects `{}` but the provenance lock records `{}`", + configured_tool.requirement, + plugin.version + ); + } + } + Ok(()) +} + +fn digest_manager_inputs( + configured: &BTreeMap, + locked: Option<&AsdfLockDocument>, +) -> Result { + let normalized = NormalizedAsdfInputs { + schema: ASDF_LOCK_SCHEMA, + tools: configured, + lock: locked, + }; + let bytes = serde_json::to_vec(&normalized) + .context("failed to serialize normalized asdf manager state")?; + let mut hasher = Sha256::new(); + hasher.update(b"zed-pkg:asdf-input:v1\0"); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + Ok(Checksum { + algorithm: ChecksumAlgorithm::Sha256, + value: hex::encode(hasher.finalize()), + }) +} + +fn is_local_reference(value: &str) -> bool { + let value = value.trim(); + value.starts_with("path:") + || value.starts_with("file:") + || value.starts_with("./") + || value.starts_with("../") + || value.starts_with('/') + || value.starts_with('\\') + || value.as_bytes().get(1) == Some(&b':') +} + +fn is_full_hex_revision(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn https_url_has_userinfo(value: &str) -> bool { + let Some(authority_and_path) = value.strip_prefix("https://") else { + return false; + }; + authority_and_path + .split('/') + .next() + .is_some_and(|authority| authority.contains('@')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn revision(digit: char) -> String { + digit.to_string().repeat(40) + } + + fn checksum(digit: char) -> String { + digit.to_string().repeat(64) + } + + fn write_lock(root: &Path, node_checksum: char, python_checksum: char) { + fs::create_dir_all(root.join(".zed")).unwrap(); + fs::write( + root.join(DEFAULT_LOCK_PATH), + 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", "aarch64-darwin"] + +[plugins.python] +version = "3.12.4" +url = "https://github.com/danhper/asdf-python.git" +revision = "{}" +sha256 = "{}" +"#, + revision('1'), + checksum(node_checksum), + revision('2'), + checksum(python_checksum), + ), + ) + .unwrap(); + } + + #[test] + fn imports_exact_project_local_asdf_provenance() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(DEFAULT_CONFIG_PATH), + "nodejs 22.11.0\npython 3.12.4\n", + ) + .unwrap(); + write_lock(temp.path(), 'a', 'b'); + + let imported = import_asdf(temp.path(), None, None, true).unwrap(); + assert_eq!(imported.config_path, DEFAULT_CONFIG_PATH); + assert_eq!(imported.lock_path.as_deref(), Some(DEFAULT_LOCK_PATH)); + assert_eq!( + imported.plan.tools["nodejs"].resolved.as_deref(), + Some("22.11.0") + ); + assert_eq!( + imported.plan.tools["nodejs"] + .source + .as_ref() + .unwrap() + .revision, + revision('1') + ); + assert_eq!(imported.plan.tools["nodejs"].checksums.len(), 1); + assert_eq!(imported.digest.len(), 64); + } + + #[test] + fn authoring_import_needs_no_sidecar() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(DEFAULT_CONFIG_PATH), "nodejs 22.11.0\n").unwrap(); + + let imported = import_asdf(temp.path(), None, None, false).unwrap(); + assert!(imported.lock_path.is_none()); + assert!(imported.plan.tools["nodejs"].resolved.is_none()); + } + + #[test] + fn frozen_import_requires_sidecar() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(DEFAULT_CONFIG_PATH), "nodejs 22.11.0\n").unwrap(); + + let error = import_asdf(temp.path(), None, None, true).unwrap_err(); + assert!(error.to_string().contains("requires `.zed/asdf.lock.toml`")); + } + + #[test] + fn multiple_fallback_versions_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(DEFAULT_CONFIG_PATH), + "nodejs 22.11.0 20.18.0\n", + ) + .unwrap(); + + let error = import_asdf(temp.path(), None, None, false).unwrap_err(); + assert!( + error + .to_string() + .contains("exactly one plugin and one version") + ); + } + + #[test] + fn lock_coverage_and_versions_must_match() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(DEFAULT_CONFIG_PATH), + "nodejs 22.12.0\npython 3.12.4\n", + ) + .unwrap(); + write_lock(temp.path(), 'a', 'b'); + + let error = import_asdf(temp.path(), None, None, true).unwrap_err(); + assert!(error.to_string().contains("asdf version drift")); + } + + #[test] + fn mutable_plugin_revision_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(DEFAULT_CONFIG_PATH), "nodejs 22.11.0\n").unwrap(); + fs::create_dir_all(temp.path().join(".zed")).unwrap(); + fs::write( + temp.path().join(DEFAULT_LOCK_PATH), + format!( + r#"schema = 1 + +[plugins.nodejs] +version = "22.11.0" +url = "https://github.com/asdf-vm/asdf-nodejs.git" +revision = "main" +sha256 = "{}" +"#, + checksum('a') + ), + ) + .unwrap(); + + let error = import_asdf(temp.path(), None, None, true).unwrap_err(); + assert!(error.to_string().contains("full 40- or 64-character")); + } + + #[test] + fn semantic_digest_ignores_comments_and_order() { + let first = tempfile::tempdir().unwrap(); + fs::write( + first.path().join(DEFAULT_CONFIG_PATH), + "# first\nnodejs 22.11.0\npython 3.12.4\n", + ) + .unwrap(); + write_lock(first.path(), 'a', 'b'); + + let second = tempfile::tempdir().unwrap(); + fs::write( + second.path().join(DEFAULT_CONFIG_PATH), + "python 3.12.4\nnodejs 22.11.0 # second\n", + ) + .unwrap(); + fs::create_dir_all(second.path().join(".zed")).unwrap(); + fs::write( + second.path().join(DEFAULT_LOCK_PATH), + format!( + r#"schema = 1 + +[plugins.python] +sha256 = "{}" +revision = "{}" +url = "https://github.com/danhper/asdf-python.git" +version = "3.12.4" + +[plugins.nodejs] +platforms = ["aarch64-darwin", "x86_64-linux"] +sha256 = "{}" +revision = "{}" +url = "https://github.com/asdf-vm/asdf-nodejs.git" +version = "22.11.0" +"#, + checksum('b'), + revision('2'), + checksum('a'), + revision('1'), + ), + ) + .unwrap(); + + let left = import_asdf(first.path(), None, None, true).unwrap(); + let right = import_asdf(second.path(), None, None, true).unwrap(); + assert_eq!(left.digest, right.digest); + assert_eq!( + left.plan.sources[0].digest.as_ref(), + right.plan.sources[0].digest.as_ref() + ); + } + + #[test] + fn artifact_checksum_changes_environment_identity() { + let first = tempfile::tempdir().unwrap(); + fs::write( + first.path().join(DEFAULT_CONFIG_PATH), + "nodejs 22.11.0\npython 3.12.4\n", + ) + .unwrap(); + write_lock(first.path(), 'a', 'b'); + + let second = tempfile::tempdir().unwrap(); + fs::write( + second.path().join(DEFAULT_CONFIG_PATH), + "nodejs 22.11.0\npython 3.12.4\n", + ) + .unwrap(); + write_lock(second.path(), 'c', 'b'); + + let left = import_asdf(first.path(), None, None, true).unwrap(); + let right = import_asdf(second.path(), None, None, true).unwrap(); + assert_ne!(left.digest, right.digest); + assert_ne!( + left.plan.sources[0].digest.as_ref(), + right.plan.sources[0].digest.as_ref() + ); + } + + #[cfg(unix)] + #[test] + fn explicit_config_symlink_cannot_escape_project_root() { + use std::os::unix::fs::symlink; + + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join(DEFAULT_CONFIG_PATH), "nodejs 22.11.0\n").unwrap(); + let project = tempfile::tempdir().unwrap(); + symlink( + outside.path().join(DEFAULT_CONFIG_PATH), + project.path().join(DEFAULT_CONFIG_PATH), + ) + .unwrap(); + + let error = import_asdf(project.path(), None, None, false).unwrap_err(); + assert!(error.to_string().contains("escapes the project root")); + } +} diff --git a/src/bin/zed-asdf.rs b/src/bin/zed-asdf.rs new file mode 100644 index 00000000..65530f72 --- /dev/null +++ b/src/bin/zed-asdf.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; +use zed_cli::asdf_environment::{import_asdf, print_import, print_verification}; + +#[derive(Debug, Parser)] +#[command( + name = "zed-asdf", + version, + about = "Project-local asdf interoperability adapter for zed-pkg" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Import `.tool-versions` and optional immutable provenance as an EnvironmentPlan. + Import(EnvironmentArgs), + /// Verify exact plugin, version, source, and artifact provenance. + Verify(EnvironmentArgs), +} + +#[derive(Debug, Args)] +struct EnvironmentArgs { + /// Project-local `.tool-versions`; defaults to the current directory. + #[arg(long, env = "ZED_PKG_ENV_CONFIG")] + config: Option, + /// Zed-owned asdf provenance sidecar; defaults to `.zed/asdf.lock.toml`. + #[arg(long, env = "ZED_PKG_ENV_LOCK")] + lock: Option, + /// Require exact portable plugin and artifact provenance. + #[arg(long, env = "ZED_PKG_FROZEN")] + frozen: bool, + /// Emit machine-readable JSON. + #[arg(long, env = "ZED_PKG_ENV_JSON")] + json: bool, +} + +fn main() { + if let Err(error) = run(Cli::parse()) { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> anyhow::Result<()> { + let cwd = std::env::current_dir()?; + match cli.command { + Command::Import(args) => { + let imported = import_asdf( + &cwd, + args.config.as_deref(), + args.lock.as_deref(), + args.frozen, + )?; + print_import(&imported, args.json) + } + Command::Verify(args) => { + let imported = import_asdf( + &cwd, + args.config.as_deref(), + args.lock.as_deref(), + args.frozen, + )?; + print_verification(&imported, args.json) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8dd478b4..811383da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! Library core of the `zed` CLI. The binary in `main.rs` is a thin //! dispatcher over these modules; integration tests drive them directly. +pub mod asdf_environment; pub mod auth; pub mod cli; pub mod cli_model; diff --git a/tests/asdf_environment_cli.rs b/tests/asdf_environment_cli.rs new file mode 100644 index 00000000..92f7a14d --- /dev/null +++ b/tests/asdf_environment_cli.rs @@ -0,0 +1,155 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed-asdf")) +} + +fn checksum(digit: char) -> String { + digit.to_string().repeat(64) +} + +fn revision(digit: char) -> String { + digit.to_string().repeat(40) +} + +fn write_fixture(root: &std::path::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 verify_is_black_box_read_only_and_does_not_need_asdf_on_path() { + let temp = tempfile::tempdir().unwrap(); + write_fixture(temp.path()); + let before_config = fs::read(temp.path().join(".tool-versions")).unwrap(); + let before_lock = fs::read(temp.path().join(".zed/asdf.lock.toml")).unwrap(); + + let output = Command::new(binary()) + .current_dir(temp.path()) + .env("PATH", "") + .args(["verify", "--frozen", "--json"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["manager"], "asdf"); + assert_eq!(value["verified"], true); + assert_eq!(value["tools"], 2); + assert_eq!( + fs::read(temp.path().join(".tool-versions")).unwrap(), + before_config + ); + assert_eq!( + fs::read(temp.path().join(".zed/asdf.lock.toml")).unwrap(), + before_lock + ); +} + +#[test] +fn frozen_verify_fails_without_provenance_sidecar() { + 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(["verify", "--frozen"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("frozen asdf import requires `.zed/asdf.lock.toml`") + ); +} + +#[test] +fn adapter_does_not_inherit_parent_tool_versions() { + let parent = tempfile::tempdir().unwrap(); + let parent_config = parent.path().join(".tool-versions"); + fs::write(&parent_config, "nodejs 22.11.0\n").unwrap(); + let child = parent.path().join("child"); + fs::create_dir_all(&child).unwrap(); + + let output = Command::new(binary()) + .current_dir(&child) + .env("PATH", "") + .args(["import", "--json"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("project-local asdf config does not exist")); + assert!(!stderr.contains(parent_config.to_string_lossy().as_ref())); +} + +#[test] +fn moving_ref_is_rejected_in_frozen_mode() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".tool-versions"), "nodejs ref:main\n").unwrap(); + fs::create_dir_all(temp.path().join(".zed")).unwrap(); + fs::write( + temp.path().join(".zed/asdf.lock.toml"), + format!( + r#"schema = 1 + +[plugins.nodejs] +version = "ref:main" +url = "https://github.com/asdf-vm/asdf-nodejs.git" +revision = "{}" +sha256 = "{}" +"#, + revision('1'), + checksum('a'), + ), + ) + .unwrap(); + + let output = Command::new(binary()) + .current_dir(temp.path()) + .env("PATH", "") + .args(["verify", "--frozen"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("moving selector")); +} From 7c02c6e154db7219044ea19c9dab833e4401363e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 10:57:33 -0500 Subject: [PATCH 02/12] ci: make asdf interop concurrency fail-closed --- .github/workflows/asdf-interop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/asdf-interop.yml b/.github/workflows/asdf-interop.yml index 85372420..f5893ba3 100644 --- a/.github/workflows/asdf-interop.yml +++ b/.github/workflows/asdf-interop.yml @@ -28,7 +28,7 @@ permissions: concurrency: group: asdf-interop-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true defaults: run: @@ -74,4 +74,4 @@ jobs: - name: Clippy (strict) run: | set -euo pipefail - cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings + cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings \ No newline at end of file From 2575180def3f4df7e4544cd338e133b49504df7a Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 11:22:42 -0500 Subject: [PATCH 03/12] ci(DEN-1420): materialize typed asdf env routing --- .../workflows/materialize-asdf-main-cli.yml | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 .github/workflows/materialize-asdf-main-cli.yml diff --git a/.github/workflows/materialize-asdf-main-cli.yml b/.github/workflows/materialize-asdf-main-cli.yml new file mode 100644 index 00000000..a0a3d6a3 --- /dev/null +++ b/.github/workflows/materialize-asdf-main-cli.yml @@ -0,0 +1,383 @@ +name: materialize typed asdf env routing + +on: + pull_request: + branches: + - agent/den-1420-asdf-current-main + paths: + - .github/workflows/materialize-asdf-main-cli.yml + +permissions: + contents: write + +concurrency: + group: materialize-asdf-main-cli + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + materialize: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'agent/den-1420-asdf-main-cli' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Check out integration branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1420-asdf-main-cli + persist-credentials: true + show-progress: false + + - name: Install Rust quality toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Wire asdf through the canonical typed dispatcher + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + cli = Path('src/cli.rs') + text = cli.read_text() + old = '''pub enum EnvironmentManagerArg { + /// Import or verify project-local mise configuration. + Mise, + } + ''' + new = '''pub enum EnvironmentManagerArg { + /// Import or verify project-local mise configuration. + Mise, + /// Import or verify project-local asdf configuration and Zed-owned provenance. + Asdf, + } + ''' + if old not in text: + raise SystemExit('EnvironmentManagerArg anchor not found') + text = text.replace(old, new, 1) + + old = ''' 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 { .. })); + } + } + ''' + new = ''' fn environment_import_and_verify_are_typed() { + 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 { .. })); + } + } + } + ''' + if old not in text: + raise SystemExit('typed environment test anchor not found') + cli.write_text(text.replace(old, new, 1)) + + main = Path('src/main.rs') + text = main.read_text() + old = 'use zed_cli::auth;\n' + new = 'use zed_cli::asdf_environment;\nuse zed_cli::auth;\n' + if old not in text: + raise SystemExit('main import anchor not found') + text = text.replace(old, new, 1) + old = 'use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, ReleaseCmd, StoreCmd};' + new = 'use zed_cli::cli::{\n AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd,\n};' + if old not in text: + raise SystemExit('main cli import anchor not found') + text = text.replace(old, new, 1) + + old = ''' Cmd::Env { cmd } => match cmd { + EnvCmd::Import { + manager: _, + config, + lock, + frozen, + json, + } => { + let imported = + environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; + environment::print_import(&imported, json) + } + EnvCmd::Verify { + manager: _, + config, + lock, + frozen, + json, + } => { + let imported = + environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; + environment::print_verification(&imported, json) + } + }, + ''' + new = ''' Cmd::Env { cmd } => match cmd { + EnvCmd::Import { + manager, + config, + lock, + frozen, + 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, + config, + lock, + frozen, + 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) + } + }, + }, + ''' + if old not in text: + raise SystemExit('main env dispatch anchor not found') + main.write_text(text.replace(old, new, 1)) + + readme = Path('README.md') + text = readme.read_text() + mise_verify = '| `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 |\n' + asdf_rows = '| `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 |\n| `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 |\n' + if asdf_rows not in text: + if mise_verify not in text: + raise SystemExit('README mise verification row not found') + text = text.replace(mise_verify, mise_verify + asdf_rows, 1) + readme.write_text(text) + + docs = Path('docs/asdf-interop.md') + text = docs.read_text() + marker = '## Commands\n' + section = '''## 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. + +''' + if section not in text: + if marker not in text: + text = section + text + else: + text = text.replace(marker, section + marker, 1) + docs.write_text(text) + PY + + - name: Add real main-binary regressions + run: | + cat > tests/asdf_main_cli.rs <<'RS' + 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`")); + } + RS + + - name: Extend permanent three-platform contract + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/asdf-interop.yml') + text = path.read_text() + old = ''' cargo test --locked --lib asdf_environment::tests + cargo test --locked --test asdf_environment_cli + ''' + new = ''' cargo test --locked --lib asdf_environment::tests + cargo test --locked --test asdf_environment_cli + cargo test --locked --test asdf_main_cli + ''' + if old not in text: + raise SystemExit('asdf workflow test anchor not found') + text = text.replace(old, new, 1) + old = 'cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings' + new = 'cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings' + if old not in text: + raise SystemExit('asdf workflow clippy anchor not found') + path.write_text(text.replace(old, new, 1)) + PY + + - name: Validate typed CLI, flags, docs, and real binaries + run: | + set -euo pipefail + cargo fmt --all + cargo fmt --all --check + cargo test --locked --lib asdf_environment::tests + cargo test --locked --test asdf_environment_cli + cargo test --locked --test asdf_main_cli + cargo test --locked cli::tests::environment_import_and_verify_are_typed + cargo test --locked cli::tests::cli_flags_toml_is_in_sync_with_clap + cargo test --locked cli::tests::readme_documents_every_command + cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings + + - name: Commit ordinary integration source + run: | + set -euo pipefail + rm .github/workflows/materialize-asdf-main-cli.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git diff --cached --check + git commit -m 'feat(DEN-1420): route asdf through zed env' + git push origin HEAD:agent/den-1420-asdf-main-cli From a8585481585c4298a4ad459b54532178f663c050 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 11:29:48 -0500 Subject: [PATCH 04/12] ci(DEN-1420): arm stacked asdf materializer --- .../kick-asdf-main-cli-materializer.yml | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/kick-asdf-main-cli-materializer.yml diff --git a/.github/workflows/kick-asdf-main-cli-materializer.yml b/.github/workflows/kick-asdf-main-cli-materializer.yml new file mode 100644 index 00000000..c73a51c8 --- /dev/null +++ b/.github/workflows/kick-asdf-main-cli-materializer.yml @@ -0,0 +1,75 @@ +name: arm stacked asdf main-CLI materializer + +on: + push: + branches: + - agent/den-1420-asdf-main-cli + paths: + - .github/workflows/kick-asdf-main-cli-materializer.yml + +permissions: + contents: write + +concurrency: + group: arm-asdf-main-cli-materializer + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + arm: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out stacked branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/den-1420-asdf-main-cli + persist-credentials: true + show-progress: false + + - name: Convert the reviewed one-shot workflow to a branch push + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/materialize-asdf-main-cli.yml') + text = path.read_text() + old = '''on: + pull_request: + branches: + - agent/den-1420-asdf-current-main + paths: + - .github/workflows/materialize-asdf-main-cli.yml + ''' + new = '''on: + push: + branches: + - agent/den-1420-asdf-main-cli + paths: + - .github/workflows/materialize-asdf-main-cli.yml + ''' + if old not in text: + raise SystemExit('materializer event anchor not found') + text = text.replace(old, new, 1) + old = " if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'agent/den-1420-asdf-main-cli' && github.actor != 'github-actions[bot]'\n" + if old not in text: + raise SystemExit('materializer job condition not found') + text = text.replace(old, '', 1) + path.write_text(text) + PY + + - name: Commit the armed materializer and remove this bootstrap + run: | + set -euo pipefail + rm .github/workflows/kick-asdf-main-cli-materializer.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git diff --cached --check + git commit -m 'ci(DEN-1420): run typed asdf routing materializer' + git push origin HEAD:agent/den-1420-asdf-main-cli From ca9aec7466728d348f186dcfd17dd891030abd5d Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 11:43:30 -0500 Subject: [PATCH 05/12] ci(DEN-1420): trigger typed asdf materializer --- .github/workflows/kick-asdf-main-cli-materializer.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/kick-asdf-main-cli-materializer.yml b/.github/workflows/kick-asdf-main-cli-materializer.yml index c73a51c8..f087b041 100644 --- a/.github/workflows/kick-asdf-main-cli-materializer.yml +++ b/.github/workflows/kick-asdf-main-cli-materializer.yml @@ -1,3 +1,6 @@ +# This second commit intentionally triggers the push workflow after the file +# already exists on the branch; the workflow removes itself after arming the +# reviewed ordinary-source materializer. name: arm stacked asdf main-CLI materializer on: From 8cb9a4a4834423820c04f979bba671256ff4e824 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 11:52:29 -0500 Subject: [PATCH 06/12] ci(DEN-1420): materialize typed asdf routing on main --- .../workflows/materialize-asdf-main-cli.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/materialize-asdf-main-cli.yml b/.github/workflows/materialize-asdf-main-cli.yml index a0a3d6a3..af03dca4 100644 --- a/.github/workflows/materialize-asdf-main-cli.yml +++ b/.github/workflows/materialize-asdf-main-cli.yml @@ -3,7 +3,7 @@ name: materialize typed asdf env routing on: pull_request: branches: - - agent/den-1420-asdf-current-main + - main paths: - .github/workflows/materialize-asdf-main-cli.yml @@ -217,14 +217,14 @@ jobs: marker = '## Commands\n' section = '''## 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] -``` + ```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. + 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. -''' + ''' if section not in text: if marker not in text: text = section + text @@ -374,7 +374,9 @@ The staged `zed-asdf` executable remains a focused adapter-test surface, but the - name: Commit ordinary integration source run: | set -euo pipefail - rm .github/workflows/materialize-asdf-main-cli.yml + rm -f \ + .github/workflows/materialize-asdf-main-cli.yml \ + .github/workflows/kick-asdf-main-cli-materializer.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A From 0ed5d0b672a13d331cb4b18b548ec24976d6035e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:19:08 -0500 Subject: [PATCH 07/12] ci(DEN-1420): commit validated asdf product files without workflow writes --- .../workflows/materialize-asdf-main-cli.yml | 38 ++++--------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/.github/workflows/materialize-asdf-main-cli.yml b/.github/workflows/materialize-asdf-main-cli.yml index af03dca4..5a7fc798 100644 --- a/.github/workflows/materialize-asdf-main-cli.yml +++ b/.github/workflows/materialize-asdf-main-cli.yml @@ -22,7 +22,7 @@ jobs: materialize: if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'agent/den-1420-asdf-main-cli' && github.actor != 'github-actions[bot]' runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 35 steps: - name: Check out integration branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -334,30 +334,6 @@ jobs: } RS - - name: Extend permanent three-platform contract - run: | - python3 - <<'PY' - from pathlib import Path - - path = Path('.github/workflows/asdf-interop.yml') - text = path.read_text() - old = ''' cargo test --locked --lib asdf_environment::tests - cargo test --locked --test asdf_environment_cli - ''' - new = ''' cargo test --locked --lib asdf_environment::tests - cargo test --locked --test asdf_environment_cli - cargo test --locked --test asdf_main_cli - ''' - if old not in text: - raise SystemExit('asdf workflow test anchor not found') - text = text.replace(old, new, 1) - old = 'cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings' - new = 'cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings' - if old not in text: - raise SystemExit('asdf workflow clippy anchor not found') - path.write_text(text.replace(old, new, 1)) - PY - - name: Validate typed CLI, flags, docs, and real binaries run: | set -euo pipefail @@ -371,15 +347,17 @@ jobs: cargo test --locked cli::tests::readme_documents_every_command cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings - - name: Commit ordinary integration source + - name: Commit ordinary product source only run: | set -euo pipefail - rm -f \ - .github/workflows/materialize-asdf-main-cli.yml \ - .github/workflows/kick-asdf-main-cli-materializer.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A + git add \ + README.md \ + docs/asdf-interop.md \ + src/cli.rs \ + src/main.rs \ + tests/asdf_main_cli.rs git diff --cached --check git commit -m 'feat(DEN-1420): route asdf through zed env' git push origin HEAD:agent/den-1420-asdf-main-cli From 69c722b1b9846254248126864e6b6ff15d2385d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:21:25 +0000 Subject: [PATCH 08/12] feat(DEN-1420): route asdf through zed env --- README.md | 2 + docs/asdf-interop.md | 9 ++++ src/cli.rs | 42 +++++++++++------ src/main.rs | 51 ++++++++++++++------ tests/asdf_main_cli.rs | 104 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 28 deletions(-) create mode 100644 tests/asdf_main_cli.rs 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`") + ); +} From 46d064e73ed4e601d4574c9ea714563be8d2d723 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:23:02 -0500 Subject: [PATCH 09/12] ci: certify canonical asdf env routing --- .github/workflows/asdf-interop.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/asdf-interop.yml b/.github/workflows/asdf-interop.yml index f5893ba3..d0350f20 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,9 @@ 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 From 17992a85ea0ef56e3de44dab625e9dc178bd7cd1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:23:14 -0500 Subject: [PATCH 10/12] chore: remove completed asdf CLI materializer bootstrap --- .../kick-asdf-main-cli-materializer.yml | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/kick-asdf-main-cli-materializer.yml diff --git a/.github/workflows/kick-asdf-main-cli-materializer.yml b/.github/workflows/kick-asdf-main-cli-materializer.yml deleted file mode 100644 index f087b041..00000000 --- a/.github/workflows/kick-asdf-main-cli-materializer.yml +++ /dev/null @@ -1,78 +0,0 @@ -# This second commit intentionally triggers the push workflow after the file -# already exists on the branch; the workflow removes itself after arming the -# reviewed ordinary-source materializer. -name: arm stacked asdf main-CLI materializer - -on: - push: - branches: - - agent/den-1420-asdf-main-cli - paths: - - .github/workflows/kick-asdf-main-cli-materializer.yml - -permissions: - contents: write - -concurrency: - group: arm-asdf-main-cli-materializer - cancel-in-progress: false - -defaults: - run: - shell: bash - -jobs: - arm: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Check out stacked branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1420-asdf-main-cli - persist-credentials: true - show-progress: false - - - name: Convert the reviewed one-shot workflow to a branch push - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path('.github/workflows/materialize-asdf-main-cli.yml') - text = path.read_text() - old = '''on: - pull_request: - branches: - - agent/den-1420-asdf-current-main - paths: - - .github/workflows/materialize-asdf-main-cli.yml - ''' - new = '''on: - push: - branches: - - agent/den-1420-asdf-main-cli - paths: - - .github/workflows/materialize-asdf-main-cli.yml - ''' - if old not in text: - raise SystemExit('materializer event anchor not found') - text = text.replace(old, new, 1) - old = " if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'agent/den-1420-asdf-main-cli' && github.actor != 'github-actions[bot]'\n" - if old not in text: - raise SystemExit('materializer job condition not found') - text = text.replace(old, '', 1) - path.write_text(text) - PY - - - name: Commit the armed materializer and remove this bootstrap - run: | - set -euo pipefail - rm .github/workflows/kick-asdf-main-cli-materializer.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git diff --cached --check - git commit -m 'ci(DEN-1420): run typed asdf routing materializer' - git push origin HEAD:agent/den-1420-asdf-main-cli From 5dccefc1009e56a90b8de71dcd7fae2299280c97 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:23:26 -0500 Subject: [PATCH 11/12] chore: remove completed asdf CLI product materializer --- .../workflows/materialize-asdf-main-cli.yml | 363 ------------------ 1 file changed, 363 deletions(-) delete mode 100644 .github/workflows/materialize-asdf-main-cli.yml diff --git a/.github/workflows/materialize-asdf-main-cli.yml b/.github/workflows/materialize-asdf-main-cli.yml deleted file mode 100644 index 5a7fc798..00000000 --- a/.github/workflows/materialize-asdf-main-cli.yml +++ /dev/null @@ -1,363 +0,0 @@ -name: materialize typed asdf env routing - -on: - pull_request: - branches: - - main - paths: - - .github/workflows/materialize-asdf-main-cli.yml - -permissions: - contents: write - -concurrency: - group: materialize-asdf-main-cli - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - materialize: - if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'agent/den-1420-asdf-main-cli' && github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 35 - steps: - - name: Check out integration branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/den-1420-asdf-main-cli - persist-credentials: true - show-progress: false - - - name: Install Rust quality toolchain - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Wire asdf through the canonical typed dispatcher - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - cli = Path('src/cli.rs') - text = cli.read_text() - old = '''pub enum EnvironmentManagerArg { - /// Import or verify project-local mise configuration. - Mise, - } - ''' - new = '''pub enum EnvironmentManagerArg { - /// Import or verify project-local mise configuration. - Mise, - /// Import or verify project-local asdf configuration and Zed-owned provenance. - Asdf, - } - ''' - if old not in text: - raise SystemExit('EnvironmentManagerArg anchor not found') - text = text.replace(old, new, 1) - - old = ''' 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 { .. })); - } - } - ''' - new = ''' fn environment_import_and_verify_are_typed() { - 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 { .. })); - } - } - } - ''' - if old not in text: - raise SystemExit('typed environment test anchor not found') - cli.write_text(text.replace(old, new, 1)) - - main = Path('src/main.rs') - text = main.read_text() - old = 'use zed_cli::auth;\n' - new = 'use zed_cli::asdf_environment;\nuse zed_cli::auth;\n' - if old not in text: - raise SystemExit('main import anchor not found') - text = text.replace(old, new, 1) - old = 'use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, ReleaseCmd, StoreCmd};' - new = 'use zed_cli::cli::{\n AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd,\n};' - if old not in text: - raise SystemExit('main cli import anchor not found') - text = text.replace(old, new, 1) - - old = ''' Cmd::Env { cmd } => match cmd { - EnvCmd::Import { - manager: _, - config, - lock, - frozen, - json, - } => { - let imported = - environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; - environment::print_import(&imported, json) - } - EnvCmd::Verify { - manager: _, - config, - lock, - frozen, - json, - } => { - let imported = - environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; - environment::print_verification(&imported, json) - } - }, - ''' - new = ''' Cmd::Env { cmd } => match cmd { - EnvCmd::Import { - manager, - config, - lock, - frozen, - 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, - config, - lock, - frozen, - 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) - } - }, - }, - ''' - if old not in text: - raise SystemExit('main env dispatch anchor not found') - main.write_text(text.replace(old, new, 1)) - - readme = Path('README.md') - text = readme.read_text() - mise_verify = '| `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 |\n' - asdf_rows = '| `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 |\n| `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 |\n' - if asdf_rows not in text: - if mise_verify not in text: - raise SystemExit('README mise verification row not found') - text = text.replace(mise_verify, mise_verify + asdf_rows, 1) - readme.write_text(text) - - docs = Path('docs/asdf-interop.md') - text = docs.read_text() - marker = '## Commands\n' - section = '''## 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. - - ''' - if section not in text: - if marker not in text: - text = section + text - else: - text = text.replace(marker, section + marker, 1) - docs.write_text(text) - PY - - - name: Add real main-binary regressions - run: | - cat > tests/asdf_main_cli.rs <<'RS' - 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`")); - } - RS - - - name: Validate typed CLI, flags, docs, and real binaries - run: | - set -euo pipefail - cargo fmt --all - cargo fmt --all --check - cargo test --locked --lib asdf_environment::tests - cargo test --locked --test asdf_environment_cli - cargo test --locked --test asdf_main_cli - cargo test --locked cli::tests::environment_import_and_verify_are_typed - cargo test --locked cli::tests::cli_flags_toml_is_in_sync_with_clap - cargo test --locked cli::tests::readme_documents_every_command - cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings - - - name: Commit ordinary product source only - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add \ - README.md \ - docs/asdf-interop.md \ - src/cli.rs \ - src/main.rs \ - tests/asdf_main_cli.rs - git diff --cached --check - git commit -m 'feat(DEN-1420): route asdf through zed env' - git push origin HEAD:agent/den-1420-asdf-main-cli From 56d1438c54d037818cf59defe11420fd59464dec Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:24:41 -0500 Subject: [PATCH 12/12] ci(DEN-1420): certify asdf routing leaves checkout clean --- .github/workflows/asdf-interop.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/asdf-interop.yml b/.github/workflows/asdf-interop.yml index d0350f20..d3916cf4 100644 --- a/.github/workflows/asdf-interop.yml +++ b/.github/workflows/asdf-interop.yml @@ -85,3 +85,6 @@ jobs: run: | set -euo pipefail 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