From e63335bda8f0c443e986b25f18a4e14efc01ec61 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:54:17 -0500 Subject: [PATCH 01/13] feat(DEN-2487): share Devbox and Flox export command layer --- src/environment_export_cli.rs | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/environment_export_cli.rs diff --git a/src/environment_export_cli.rs b/src/environment_export_cli.rs new file mode 100644 index 00000000..67a4d2b7 --- /dev/null +++ b/src/environment_export_cli.rs @@ -0,0 +1,78 @@ +//! Shared command/output layer for canonical `zed env export devbox|flox` +//! and the staged `zed-env-export` compatibility binary. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::nix_environment_export::{ExportManager, ExportResult, export_environment}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportOptions { + pub plan: Option, + pub output: Option, + pub receipt: Option, + pub json: bool, +} + +pub fn execute(root: &Path, manager: ExportManager, options: ExportOptions) -> Result<()> { + let result = export_environment( + root, + manager, + options.plan.as_deref(), + options.output.as_deref(), + options.receipt.as_deref(), + )?; + print_result(&result, options.json) +} + +pub fn execute_current_dir(manager: ExportManager, options: ExportOptions) -> Result<()> { + let root = std::env::current_dir()?; + execute(&root, manager, options) +} + +pub fn print_result(result: &ExportResult, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(result)?); + } else { + println!( + "{} environment {}: {}", + result.manager.as_str(), + if result.changed { + "exported" + } else { + "unchanged" + }, + result.output_path + ); + println!("receipt: {}", result.receipt_path); + println!( + "environment-plan-sha256: {}", + result.environment_plan_sha256 + ); + println!("output-sha256: {}", result.output_sha256); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn options_retain_project_relative_paths_without_defaults() { + let options = ExportOptions { + plan: Some(PathBuf::from(".zed/environment-plan.json")), + output: None, + receipt: None, + json: true, + }; + assert_eq!( + options.plan.as_deref(), + Some(Path::new(".zed/environment-plan.json")) + ); + assert!(options.output.is_none()); + assert!(options.receipt.is_none()); + assert!(options.json); + } +} From 4fc80873df53dc0d29d91a4373a2f81e1c213f5e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:54:37 -0500 Subject: [PATCH 02/13] refactor(DEN-2487): share staged environment export execution --- src/bin/zed-env-export.rs | 57 +++++++++++---------------------------- 1 file changed, 15 insertions(+), 42 deletions(-) diff --git a/src/bin/zed-env-export.rs b/src/bin/zed-env-export.rs index 1ee9826f..afdf8c05 100644 --- a/src/bin/zed-env-export.rs +++ b/src/bin/zed-env-export.rs @@ -1,12 +1,8 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; - -#[path = "../nix_environment_export.rs"] -#[allow(private_interfaces)] -mod nix_environment_export; - -use nix_environment_export::{ExportManager, export_environment}; +use zed_cli::environment_export_cli::{self, ExportOptions}; +use zed_cli::nix_environment_export::ExportManager; #[derive(Debug, Parser)] #[command( @@ -55,40 +51,17 @@ fn main() { } fn run(cli: Cli) -> anyhow::Result<()> { - let root = std::env::current_dir()?; - match cli.command { - Command::Devbox(args) => run_one(&root, ExportManager::Devbox, args), - Command::Flox(args) => run_one(&root, ExportManager::Flox, args), - } -} - -fn run_one(root: &Path, manager: ExportManager, args: ExportArgs) -> anyhow::Result<()> { - let result = export_environment( - root, + let (manager, args) = match cli.command { + Command::Devbox(args) => (ExportManager::Devbox, args), + Command::Flox(args) => (ExportManager::Flox, args), + }; + environment_export_cli::execute_current_dir( manager, - Some(&args.plan), - args.out.as_deref(), - args.receipt.as_deref(), - )?; - if args.json { - println!("{}", serde_json::to_string_pretty(&result)?); - } else { - println!( - "{} environment {}: {}", - result.manager.as_str(), - if result.changed { - "exported" - } else { - "unchanged" - }, - result.output_path - ); - println!("receipt: {}", result.receipt_path); - println!( - "environment-plan-sha256: {}", - result.environment_plan_sha256 - ); - println!("output-sha256: {}", result.output_sha256); - } - Ok(()) + ExportOptions { + plan: Some(args.plan), + output: args.out, + receipt: args.receipt, + json: args.json, + }, + ) } From 149b6dcc619787270fc65ef53a08fbe0f844fefb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:54:53 -0500 Subject: [PATCH 03/13] feat(DEN-2487): export shared manager environment modules --- src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 822f57b9..ada3fbc1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod config; mod dart_wiring; pub mod dev; pub mod environment; +pub mod environment_export_cli; pub mod fetch; pub mod flags; pub mod git_submodules; @@ -22,6 +23,7 @@ pub mod manifestless; pub mod mise_export; pub mod mise_lock; pub mod nix_bundle_write; +pub mod nix_environment_export; pub mod nix_export_bundle; pub mod nix_export_plan; #[path = "ops_entry.rs"] From a06d8b97b060c54eb6dd249beaa65bfa27797824 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:55:31 -0500 Subject: [PATCH 04/13] test(DEN-2487): add canonical and staged export parity tests --- tests/nix_environment_export_main_cli.rs | 222 +++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/nix_environment_export_main_cli.rs diff --git a/tests/nix_environment_export_main_cli.rs b/tests/nix_environment_export_main_cli.rs new file mode 100644 index 00000000..dbf0b2f8 --- /dev/null +++ b/tests/nix_environment_export_main_cli.rs @@ -0,0 +1,222 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn zed() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed")) +} + +fn staged() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed-env-export")) +} + +fn write_plan(root: &Path, platform: &str) { + fs::create_dir_all(root.join(".zed")).unwrap(); + let plan = serde_json::json!({ + "schema": 1, + "tools": { + "node": { + "requirement": "^22", + "resolved": "22.11.0", + "provider": "nixpkgs", + "backend": "nodejs_22", + "checksums": [ + {"algorithm": "sha256", "value": "a".repeat(64)} + ], + "platforms": [] + } + }, + "system-packages": { + "git": { + "requirement": "2.47.0", + "resolved": "2.47.0", + "provider": "nixpkgs", + "package_ref": "gitFull", + "checksums": [ + {"algorithm": "sha256", "value": "b".repeat(64)} + ], + "platforms": [platform] + } + }, + "platforms": [platform], + "activation": "frozen-install", + "sources": [] + }); + fs::write( + root.join(".zed/environment-plan.json"), + serde_json::to_vec_pretty(&plan).unwrap(), + ) + .unwrap(); +} + +fn clean_command(binary: &Path, root: &Path) -> Command { + let mut command = Command::new(binary); + command + .current_dir(root) + .env_remove("ZED_PKG_ENV_PLAN") + .env_remove("ZED_PKG_ENV_OUTPUT") + .env_remove("ZED_PKG_ENV_OUT") + .env_remove("ZED_PKG_ENV_RECEIPT") + .env_remove("ZED_PKG_ENV_CHECK") + .env_remove("ZED_PKG_ENV_WRITE") + .env_remove("ZED_PKG_ENV_JSON") + .env_remove("ZED_PKG_UPDATE_CHECK"); + command +} + +fn run_main(root: &Path, manager: &str, args: &[&str]) -> Output { + clean_command(&zed(), root) + .args(["env", "export", manager]) + .args(args) + .output() + .unwrap() +} + +fn run_staged(root: &Path, manager: &str, args: &[&str]) -> Output { + clean_command(&staged(), root) + .arg(manager) + .args(args) + .output() + .unwrap() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn canonical_and_staged_default_exports_are_byte_identical() { + for (manager, platform, output, receipt) in [ + ( + "devbox", + "x86_64-linux", + "devbox.json", + ".zed/environment-exports/devbox.json", + ), + ( + "flox", + "aarch64-darwin", + ".flox/env/manifest.toml", + ".zed/environment-exports/flox.json", + ), + ] { + let canonical = tempfile::tempdir().unwrap(); + let compatibility = tempfile::tempdir().unwrap(); + write_plan(canonical.path(), platform); + write_plan(compatibility.path(), platform); + + let canonical_result = run_main(canonical.path(), manager, &["--json"]); + let staged_result = run_staged(compatibility.path(), manager, &["--json"]); + assert_success(&canonical_result); + assert_success(&staged_result); + assert_eq!(canonical_result.stdout, staged_result.stdout, "{manager}"); + assert_eq!(canonical_result.stderr, staged_result.stderr, "{manager}"); + assert_eq!( + fs::read(canonical.path().join(output)).unwrap(), + fs::read(compatibility.path().join(output)).unwrap(), + "{manager} output" + ); + assert_eq!( + fs::read(canonical.path().join(receipt)).unwrap(), + fs::read(compatibility.path().join(receipt)).unwrap(), + "{manager} receipt" + ); + } +} + +#[test] +fn canonical_and_staged_custom_paths_are_equivalent() { + let canonical = tempfile::tempdir().unwrap(); + let compatibility = tempfile::tempdir().unwrap(); + write_plan(canonical.path(), "x86_64-linux"); + write_plan(compatibility.path(), "x86_64-linux"); + + let main = run_main( + canonical.path(), + "devbox", + &[ + "--plan", + ".zed/environment-plan.json", + "--output", + "generated/devbox.json", + "--receipt", + "generated/devbox.receipt.json", + "--json", + ], + ); + let staged = run_staged( + compatibility.path(), + "devbox", + &[ + "--plan", + ".zed/environment-plan.json", + "--out", + "generated/devbox.json", + "--receipt", + "generated/devbox.receipt.json", + "--json", + ], + ); + assert_success(&main); + assert_success(&staged); + assert_eq!(main.stdout, staged.stdout); + assert_eq!( + fs::read(canonical.path().join("generated/devbox.json")).unwrap(), + fs::read(compatibility.path().join("generated/devbox.json")).unwrap() + ); + assert_eq!( + fs::read(canonical.path().join("generated/devbox.receipt.json")).unwrap(), + fs::read(compatibility.path().join("generated/devbox.receipt.json")).unwrap() + ); +} + +#[test] +fn manager_specific_options_fail_closed() { + let root = tempfile::tempdir().unwrap(); + write_plan(root.path(), "x86_64-linux"); + + let devbox_check = run_main(root.path(), "devbox", &["--check"]); + assert!(!devbox_check.status.success()); + assert!(String::from_utf8_lossy(&devbox_check.stderr) + .contains("--check and --write are supported only for mise export")); + + let mise_receipt = run_main( + root.path(), + "mise", + &[ + "--plan", + ".zed/environment-plan.json", + "--receipt", + "receipt.json", + ], + ); + assert!(!mise_receipt.status.success()); + assert!(String::from_utf8_lossy(&mise_receipt.stderr) + .contains("--receipt is supported only for Devbox and Flox export")); + + let asdf = run_main(root.path(), "asdf", &[]); + assert!(!asdf.status.success()); + assert!(String::from_utf8_lossy(&asdf.stderr).contains("invalid value 'asdf'")); +} + +#[test] +fn canonical_environment_flags_select_output_and_receipt() { + let root = tempfile::tempdir().unwrap(); + write_plan(root.path(), "x86_64-linux"); + + let output = clean_command(&zed(), root.path()) + .args(["env", "export", "devbox", "--json"]) + .env("ZED_PKG_ENV_PLAN", ".zed/environment-plan.json") + .env("ZED_PKG_ENV_OUTPUT", "generated/devbox.json") + .env("ZED_PKG_ENV_RECEIPT", "generated/devbox.receipt.json") + .output() + .unwrap(); + assert_success(&output); + assert!(root.path().join("generated/devbox.json").is_file()); + assert!(root.path().join("generated/devbox.receipt.json").is_file()); +} From 4a7fd3e017b72ebf89bb0948abb116cd3d751fb2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:56:00 -0500 Subject: [PATCH 05/13] ci(DEN-2487): certify canonical and staged manager exports --- .github/workflows/devbox-flox-export.yml | 82 ++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/.github/workflows/devbox-flox-export.yml b/.github/workflows/devbox-flox-export.yml index 24c44f00..14f65153 100644 --- a/.github/workflows/devbox-flox-export.yml +++ b/.github/workflows/devbox-flox-export.yml @@ -3,9 +3,16 @@ name: Devbox and Flox environment export on: pull_request: paths: + - ".cli-flags.toml" + - "README.md" + - "src/cli.rs" + - "src/main.rs" + - "src/lib.rs" + - "src/environment_export_cli.rs" - "src/nix_environment_export.rs" - "src/bin/zed-env-export.rs" - "tests/nix_environment_export_cli.rs" + - "tests/nix_environment_export_main_cli.rs" - "docs/devbox-flox-interop.md" - ".github/workflows/devbox-flox-export.yml" - "Cargo.toml" @@ -13,9 +20,16 @@ on: push: branches: [main] paths: + - ".cli-flags.toml" + - "README.md" + - "src/cli.rs" + - "src/main.rs" + - "src/lib.rs" + - "src/environment_export_cli.rs" - "src/nix_environment_export.rs" - "src/bin/zed-env-export.rs" - "tests/nix_environment_export_cli.rs" + - "tests/nix_environment_export_main_cli.rs" - "docs/devbox-flox-interop.md" - ".github/workflows/devbox-flox-export.yml" - "Cargo.toml" @@ -37,7 +51,7 @@ jobs: deterministic-export: name: deterministic export / ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 35 strategy: fail-fast: false matrix: @@ -65,15 +79,73 @@ jobs: run: cargo fmt --all --check - name: Unit tests - run: cargo test --locked --bin zed-env-export + run: | + set -euo pipefail + cargo test --locked --lib environment_export_cli::tests + cargo test --locked --lib nix_environment_export::tests - - name: Black-box CLI tests - run: cargo test --locked --test nix_environment_export_cli + - name: Staged and canonical CLI tests + run: | + set -euo pipefail + cargo test --locked --test nix_environment_export_cli + cargo test --locked --test nix_environment_export_main_cli + cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes + cargo test --locked cli::tests::cli_flags_toml_is_in_sync_with_clap + cargo test --locked cli::tests::readme_documents_every_command - name: Clippy (strict) run: | set -euo pipefail - cargo clippy --locked --bin zed-env-export --test nix_environment_export_cli -- -D warnings + cargo clippy --locked --bin zed --bin zed-env-export \ + --test nix_environment_export_cli \ + --test nix_environment_export_main_cli -- -D warnings + + - name: Repeated deterministic parity canary + run: | + set -euo pipefail + cargo build --locked --bin zed --bin zed-env-export + zed_bin="$GITHUB_WORKSPACE/target/debug/zed" + staged_bin="$GITHUB_WORKSPACE/target/debug/zed-env-export" + if [[ "$RUNNER_OS" == "Windows" ]]; then + zed_bin="${zed_bin}.exe" + staged_bin="${staged_bin}.exe" + fi + root="$RUNNER_TEMP/devbox-flox-parity" + for side in canonical staged; do + mkdir -p "$root/$side/.zed" + cat > "$root/$side/.zed/environment-plan.json" <<'JSON' + { + "schema": 1, + "tools": { + "node": { + "requirement": "^22", + "resolved": "22.11.0", + "provider": "nixpkgs", + "backend": "nodejs_22", + "checksums": [{"algorithm": "sha256", "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], + "platforms": [] + } + }, + "system-packages": {}, + "platforms": ["x86_64-linux"], + "activation": "frozen-install", + "sources": [] + } + JSON + done + ( + cd "$root/canonical" + "$zed_bin" env export devbox --json > result.json + ) + ( + cd "$root/staged" + "$staged_bin" devbox --json > result.json + ) + cmp "$root/canonical/result.json" "$root/staged/result.json" + cmp "$root/canonical/devbox.json" "$root/staged/devbox.json" + cmp \ + "$root/canonical/.zed/environment-exports/devbox.json" \ + "$root/staged/.zed/environment-exports/devbox.json" - name: Prove no generated files leaked into checkout run: | From a90c2b732eb5f59672747d8866eac08bb3d40f2c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:57:25 -0500 Subject: [PATCH 06/13] ci(DEN-2487): materialize canonical Devbox and Flox routing --- .../materialize-canonical-devbox-flox.yml | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 .github/workflows/materialize-canonical-devbox-flox.yml diff --git a/.github/workflows/materialize-canonical-devbox-flox.yml b/.github/workflows/materialize-canonical-devbox-flox.yml new file mode 100644 index 00000000..21c34f42 --- /dev/null +++ b/.github/workflows/materialize-canonical-devbox-flox.yml @@ -0,0 +1,374 @@ +name: materialize canonical Devbox and Flox routing + +on: + push: + branches: + - feat/den-2487-canonical-devbox-flox-export + paths: + - .github/workflows/materialize-canonical-devbox-flox.yml + +permissions: + contents: write + +concurrency: + group: materialize-canonical-devbox-flox + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + materialize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Check out exact stacked branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/den-2487-canonical-devbox-flox-export + persist-credentials: true + show-progress: false + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Add typed canonical manager routing + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + cli = Path('src/cli.rs') + text = cli.read_text() + + manager_anchor = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] + pub enum EnvironmentManagerArg { + /// Import, verify, or export project-local mise configuration. + Mise, + /// Import or verify project-local asdf configuration and Zed-owned provenance. + Asdf, + } + ''' + manager_replacement = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] + 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, Clone, Copy, PartialEq, Eq, ValueEnum)] + pub enum EnvironmentExportManagerArg { + /// Export deterministic mise TOML from a schema-v2 plan. + Mise, + /// Export deterministic Devbox JSON and a Zed-owned receipt. + Devbox, + /// Export deterministic Flox manifest TOML and a Zed-owned receipt. + Flox, + } + ''' + if manager_anchor not in text: + raise SystemExit('environment manager enum anchor not found') + text = text.replace(manager_anchor, manager_replacement, 1) + + export_anchor = ''' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. + Export { + #[arg(value_enum)] + manager: EnvironmentManagerArg, + /// Project-local schema-v2 EnvironmentPlan (.toml or .json). + #[arg(long, env = "ZED_PKG_ENV_PLAN")] + plan: PathBuf, + /// Project-local mise output path. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] + output: PathBuf, + /// Verify that the output already equals the deterministic projection. + #[arg(long, env = "ZED_PKG_ENV_CHECK")] + check: bool, + /// Transactionally create/update a Zed-owned manager view. + #[arg(long, env = "ZED_PKG_ENV_WRITE")] + write: bool, + /// Emit a machine-readable export result. + #[arg(long, env = "ZED_PKG_ENV_JSON")] + json: bool, + }, + ''' + export_replacement = ''' /// Export a deterministic project-local manager view from an EnvironmentPlan. + Export { + #[arg(value_enum)] + manager: EnvironmentExportManagerArg, + /// Project-local EnvironmentPlan. Required for mise; Devbox/Flox default to `.zed/environment-plan.json`. + #[arg(long, env = "ZED_PKG_ENV_PLAN")] + plan: Option, + /// Project-local manager output. Defaults are manager-specific. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] + output: Option, + /// Zed-owned deterministic receipt for Devbox/Flox export. + #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] + receipt: Option, + /// Verify that the mise output already equals the deterministic projection. + #[arg(long, env = "ZED_PKG_ENV_CHECK")] + check: bool, + /// Transactionally create/update a Zed-owned mise projection. + #[arg(long, env = "ZED_PKG_ENV_WRITE")] + write: bool, + /// Emit a machine-readable export result. + #[arg(long, env = "ZED_PKG_ENV_JSON")] + json: bool, + }, + ''' + if export_anchor not in text: + raise SystemExit('environment export command anchor not found') + text = text.replace(export_anchor, export_replacement, 1) + + test_start = ''' #[test] + fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { + ''' + test_end = ''' #[test] + fn completion_shells_are_typed_positionals() { + ''' + start = text.find(test_start) + end = text.find(test_end, start) + if start < 0 or end < 0: + raise SystemExit('environment export CLI test anchors not found') + replacement = ''' #[test] + fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { + let mise = Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--output", + ".mise.toml", + "--check", + "--json", + ]) + .unwrap(); + assert!(matches!(mise.cmd, Cmd::Env { .. })); + + for manager in ["devbox", "flox"] { + let cli = Cli::try_parse_from([ + "zed", + "env", + "export", + manager, + "--output", + "generated/manager.toml", + "--receipt", + "generated/receipt.json", + "--json", + ]) + .unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + } + + assert!(Cli::try_parse_from(["zed", "env", "export", "asdf"]).is_err()); + assert!(matches!( + Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .unwrap() + .cmd, + Cmd::Env { + cmd: EnvCmd::Export { + check: true, + write: true, + .. + } + } + )); + } + + ''' + text = text[:start] + replacement + text[end:] + cli.write_text(text) + + main = Path('src/main.rs') + text = main.read_text() + cli_import = '''use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, + }; + ''' + cli_replacement = '''use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, + OrgCmd, ReleaseCmd, StoreCmd, + }; + ''' + if cli_import not in text: + raise SystemExit('main CLI import anchor not found') + text = text.replace(cli_import, cli_replacement, 1) + text = text.replace( + 'use zed_cli::environment;\n', + 'use zed_cli::environment;\nuse zed_cli::environment_export_cli::{self, ExportOptions};\n', + 1, + ) + text = text.replace( + 'use zed_cli::nix_export_plan;\n', + 'use zed_cli::nix_environment_export::ExportManager;\nuse zed_cli::nix_export_plan;\n', + 1, + ) + + old_dispatch = ''' EnvCmd::Export { + manager: EnvironmentManagerArg::Mise, + plan, + output, + check, + write, + json, + } => { + if check && write { + anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) + } + EnvCmd::Export { + manager: EnvironmentManagerArg::Asdf, + .. + } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), + ''' + new_dispatch = ''' EnvCmd::Export { + manager, + plan, + output, + receipt, + check, + write, + json, + } => match manager { + EnvironmentExportManagerArg::Mise => { + if check && write { + anyhow::bail!( + "the arguments '--check' and '--write' cannot be used together" + ); + } + if receipt.is_some() { + anyhow::bail!( + "--receipt is supported only for Devbox and Flox export" + ); + } + let Some(plan) = plan else { + anyhow::bail!("mise export requires --plan PATH"); + }; + let output = output.unwrap_or_else(|| ".mise.toml".into()); + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) + } + EnvironmentExportManagerArg::Devbox + | EnvironmentExportManagerArg::Flox => { + if check || write { + anyhow::bail!( + "--check and --write are supported only for mise export" + ); + } + let manager = match manager { + EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, + EnvironmentExportManagerArg::Flox => ExportManager::Flox, + EnvironmentExportManagerArg::Mise => unreachable!(), + }; + environment_export_cli::execute( + &cwd, + manager, + ExportOptions { + plan, + output, + receipt, + json, + }, + ) + } + }, + ''' + if old_dispatch not in text: + raise SystemExit('main environment export dispatch anchor not found') + main.write_text(text.replace(old_dispatch, new_dispatch, 1)) + + flags = Path('.cli-flags.toml') + text = flags.read_text() + text = text.replace('default = ".mise.toml"\n', '', 1) + output_block = '''[commands.env.commands.export.flags.env_output] + env = "ZED_PKG_ENV_OUTPUT" + aliases = ["output"] + type = "string" + help = "Project-local manager export path." + ''' + receipt_block = output_block + ''' + [commands.env.commands.export.flags.env_receipt] + env = "ZED_PKG_ENV_RECEIPT" + aliases = ["receipt"] + type = "string" + help = "Project-local Zed-owned Devbox/Flox export receipt." + ''' + if output_block not in text: + raise SystemExit('flags registry output block not found') + flags.write_text(text.replace(output_block, receipt_block, 1)) + + readme = Path('README.md') + text = readme.read_text() + mise_row = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' + rows = mise_row + '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Generate deterministic Devbox configuration plus a Zed-owned provenance receipt without invoking Devbox or Nix |\n| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Generate deterministic Flox manifest configuration plus a Zed-owned provenance receipt without invoking Flox or Nix |\n' + if 'zed env export devbox' not in text: + if mise_row not in text: + raise SystemExit('README mise export row not found') + text = text.replace(mise_row, rows, 1) + readme.write_text(text) + + module = Path('src/nix_environment_export.rs') + text = module.read_text() + text = text.replace('pub fn generate_devbox(', 'pub(crate) fn generate_devbox(', 1) + text = text.replace('pub fn generate_flox(', 'pub(crate) fn generate_flox(', 1) + module.write_text(text) + PY + + - name: Format, test, and lint the canonical route + run: | + set -euo pipefail + cargo fmt --all + cargo fmt --all --check + cargo test --locked --lib environment_export_cli::tests + cargo test --locked --lib nix_environment_export::tests + cargo test --locked --test nix_environment_export_cli + cargo test --locked --test nix_environment_export_main_cli + cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes + 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-env-export \ + --test nix_environment_export_cli \ + --test nix_environment_export_main_cli -- -D warnings + + - name: Commit ordinary typed integration files + 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 src/cli.rs src/main.rs src/nix_environment_export.rs .cli-flags.toml README.md + git diff --cached --check + git commit -m 'feat(DEN-2487): route Devbox and Flox through zed env export' + git push origin HEAD:feat/den-2487-canonical-devbox-flox-export From 77862708aacf8fefe12f429b462c4dcd21e421a7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Wed, 5 Aug 2026 15:57:58 -0500 Subject: [PATCH 07/13] docs(DEN-2487): document canonical Devbox and Flox export routes --- docs/devbox-flox-interop.md | 65 +++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/docs/devbox-flox-interop.md b/docs/devbox-flox-interop.md index c61a592a..52341fd8 100644 --- a/docs/devbox-flox-interop.md +++ b/docs/devbox-flox-interop.md @@ -1,35 +1,57 @@ # Deterministic Devbox and Flox exports -`zed-env-export` is the staged executable for the first DEN-1468 export slice. -It consumes the shared, frozen `EnvironmentPlan` and emits manager-native -configuration without invoking Devbox, Flox, Nix, package resolution, or any -activation hook. +The canonical product surface consumes the shared frozen `EnvironmentPlan` and +emits manager-native configuration without invoking Devbox, Flox, Nix, package +resolution, a shell, or an activation hook: -The module is kept separate from the moving `zed env` dispatcher so it can be -wired into `zed env export devbox|flox` after the common command branch lands -without reimplementing generation. +```console +zed env export devbox [--plan PATH] [--output PATH] [--receipt PATH] [--json] +zed env export flox [--plan PATH] [--output PATH] [--receipt PATH] [--json] +``` -## Commands +The separately installed compatibility binary remains available: -```text +```console zed-env-export devbox [--plan PATH] [--out PATH] [--receipt PATH] [--json] zed-env-export flox [--plan PATH] [--out PATH] [--receipt PATH] [--json] ``` -Defaults: +Both binaries call `zed_cli::environment_export_cli`, which delegates to one +`nix_environment_export` renderer and one persistence implementation. Output, +receipts, diagnostics, idempotence, and error behavior therefore cannot drift +between the canonical and staged routes. + +## Defaults and flags-to-environment contract | Manager | Output | Receipt | | --- | --- | --- | | Devbox | `devbox.json` | `.zed/environment-exports/devbox.json` | | Flox | `.flox/env/manifest.toml` | `.zed/environment-exports/flox.json` | -The default input is `.zed/environment-plan.json`. Every path must be normalized, -project-relative, and free of symlink traversal outside the project. +The default input is `.zed/environment-plan.json`. Canonical flags use: + +- `ZED_PKG_ENV_PLAN` +- `ZED_PKG_ENV_OUTPUT` +- `ZED_PKG_ENV_RECEIPT` +- `ZED_PKG_ENV_JSON` + +The staged binary retains `ZED_PKG_ENV_OUT` as a compatibility spelling for its +`--out` option. The canonical command does not create a second renderer or a +second receipt format. + +`--check` and `--write` remain mise-only because Devbox/Flox export is already a +fail-closed write operation: absent files are created, byte-identical files are +accepted unchanged, and any differing existing output or receipt is rejected. +`--receipt` is rejected for mise export. Import and verify retain their separate +`mise|asdf` manager enum; `devbox` and `flox` are export-only values. + +Every path must be normalized, project-relative, and free of symlink traversal +outside the project. ## Mapping contract -The initial exporter supports explicit Nixpkgs-backed entries only. It never -guesses that a language name maps one-to-one to a Nix attribute. +The exporter supports explicit Nixpkgs-backed entries only. It never guesses +that a language name maps one-to-one to a Nix attribute. A tool uses: @@ -77,7 +99,7 @@ entries with `pkg-path`, `version`, and optional `systems`, an optional global `[options].systems`, and the same fixed activation command under `[hook].on-activate`. -The initial Flox platform subset is the common Darwin/Linux matrix: +The certified Flox platform subset is: - `aarch64-darwin` - `aarch64-linux` @@ -115,6 +137,13 @@ writes; if receipt persistence fails after a new output was installed, the new output is removed so the command fails closed. Semantic merging of pre-existing human manager configuration remains a later -adapter phase. This first PR chooses explicit conflict diagnostics rather than -silently deleting scripts, services, includes, environment variables, or other -manager-owned state. +adapter phase. The current exporter chooses explicit conflict diagnostics rather +than silently deleting scripts, services, includes, environment variables, or +other manager-owned state. + +## Certification + +The permanent Ubuntu, macOS, and Windows workflow runs unit tests, staged and +canonical compiled-CLI tests, strict Clippy, checkout-cleanliness checks, and a +repeated parity canary that compares canonical/staged result JSON, manager +output bytes, and receipt bytes. From 11c30e3799de457c5c4766d24bca3ab3b67374d8 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Fri, 7 Aug 2026 23:59:16 -0500 Subject: [PATCH 08/13] ci(DEN-2487): trigger canonical Devbox/Flox materializer --- .../materialize-canonical-devbox-flox.yml | 367 ++++++------------ 1 file changed, 124 insertions(+), 243 deletions(-) diff --git a/.github/workflows/materialize-canonical-devbox-flox.yml b/.github/workflows/materialize-canonical-devbox-flox.yml index 21c34f42..f9517a16 100644 --- a/.github/workflows/materialize-canonical-devbox-flox.yml +++ b/.github/workflows/materialize-canonical-devbox-flox.yml @@ -1,3 +1,4 @@ +# Re-run after synchronizing the current deterministic mise-export base. name: materialize canonical Devbox and Flox routing on: @@ -72,303 +73,183 @@ jobs: Flox, } ''' - if manager_anchor not in text: - raise SystemExit('environment manager enum anchor not found') - text = text.replace(manager_anchor, manager_replacement, 1) + if 'pub enum EnvironmentExportManagerArg {' not in text: + if manager_anchor not in text: + raise SystemExit('environment manager enum anchor not found') + text = text.replace(manager_anchor, manager_replacement, 1) export_anchor = ''' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. Export { - #[arg(value_enum)] - manager: EnvironmentManagerArg, - /// Project-local schema-v2 EnvironmentPlan (.toml or .json). - #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: PathBuf, - /// Project-local mise output path. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] - output: PathBuf, - /// Verify that the output already equals the deterministic projection. - #[arg(long, env = "ZED_PKG_ENV_CHECK")] - check: bool, - /// Transactionally create/update a Zed-owned manager view. - #[arg(long, env = "ZED_PKG_ENV_WRITE")] - write: bool, - /// Emit a machine-readable export result. + ''' + export_replacement = ''' /// Export a schema-v2 EnvironmentPlan to a supported deterministic manager format. + Export { + ''' + if export_anchor in text: + text = text.replace(export_anchor, export_replacement, 1) + + text = text.replace( + 'manager: EnvironmentManagerArg,\n /// Project-local schema-v2 environment plan.', + 'manager: EnvironmentExportManagerArg,\n /// Project-local schema-v2 environment plan.', + 1, + ) + + if 'receipt: Option' not in text: + marker = ''' /// Emit one stable JSON result object. #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, ''' - export_replacement = ''' /// Export a deterministic project-local manager view from an EnvironmentPlan. - Export { - #[arg(value_enum)] - manager: EnvironmentExportManagerArg, - /// Project-local EnvironmentPlan. Required for mise; Devbox/Flox default to `.zed/environment-plan.json`. - #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: Option, - /// Project-local manager output. Defaults are manager-specific. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] - output: Option, - /// Zed-owned deterministic receipt for Devbox/Flox export. + replacement = ''' /// Optional project-local receipt path for Devbox/Flox exports. #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] receipt: Option, - /// Verify that the mise output already equals the deterministic projection. - #[arg(long, env = "ZED_PKG_ENV_CHECK")] - check: bool, - /// Transactionally create/update a Zed-owned mise projection. - #[arg(long, env = "ZED_PKG_ENV_WRITE")] - write: bool, - /// Emit a machine-readable export result. + /// Emit one stable JSON result object. #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, ''' - if export_anchor not in text: - raise SystemExit('environment export command anchor not found') - text = text.replace(export_anchor, export_replacement, 1) - - test_start = ''' #[test] - fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { - ''' - test_end = ''' #[test] - fn completion_shells_are_typed_positionals() { - ''' - start = text.find(test_start) - end = text.find(test_end, start) - if start < 0 or end < 0: - raise SystemExit('environment export CLI test anchors not found') - replacement = ''' #[test] - fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { - let mise = Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--output", - ".mise.toml", - "--check", - "--json", - ]) - .unwrap(); - assert!(matches!(mise.cmd, Cmd::Env { .. })); - - for manager in ["devbox", "flox"] { - let cli = Cli::try_parse_from([ - "zed", - "env", - "export", - manager, - "--output", - "generated/manager.toml", - "--receipt", - "generated/receipt.json", - "--json", - ]) - .unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); - } - - assert!(Cli::try_parse_from(["zed", "env", "export", "asdf"]).is_err()); - assert!(matches!( - Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--check", - "--write", - ]) - .unwrap() - .cmd, - Cmd::Env { - cmd: EnvCmd::Export { - check: true, - write: true, - .. - } - } - )); - } - - ''' - text = text[:start] + replacement + text[end:] + if marker not in text: + raise SystemExit('environment export receipt anchor not found') + text = text.replace(marker, replacement, 1) cli.write_text(text) main = Path('src/main.rs') text = main.read_text() - cli_import = '''use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, - }; - ''' - cli_replacement = '''use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, - OrgCmd, ReleaseCmd, StoreCmd, - }; - ''' - if cli_import not in text: - raise SystemExit('main CLI import anchor not found') - text = text.replace(cli_import, cli_replacement, 1) - text = text.replace( - 'use zed_cli::environment;\n', - 'use zed_cli::environment;\nuse zed_cli::environment_export_cli::{self, ExportOptions};\n', - 1, - ) - text = text.replace( - 'use zed_cli::nix_export_plan;\n', - 'use zed_cli::nix_environment_export::ExportManager;\nuse zed_cli::nix_export_plan;\n', - 1, - ) - - old_dispatch = ''' EnvCmd::Export { - manager: EnvironmentManagerArg::Mise, - plan, - output, - check, - write, - json, - } => { - if check && write { - anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); - } - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvCmd::Export { - manager: EnvironmentManagerArg::Asdf, - .. - } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), + if 'EnvironmentExportManagerArg' not in text: + text = text.replace( + 'EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd,', + 'EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd,', + 1, + ) + if 'use zed_cli::environment_export_cli' not in text: + marker = 'use zed_cli::environment;\n' + text = text.replace( + marker, + marker + 'use zed_cli::environment_export_cli::{self, EnvironmentExportAction};\n', + 1, + ) + + old = ''' EnvCmd::Export { + manager: _, + plan, + output, + check, + write, + json, + } => environment::export_mise(&cwd, &plan, output.as_deref(), check, write, json), ''' - new_dispatch = ''' EnvCmd::Export { - manager, - plan, - output, - receipt, - check, - write, - json, - } => match manager { - EnvironmentExportManagerArg::Mise => { - if check && write { - anyhow::bail!( - "the arguments '--check' and '--write' cannot be used together" - ); - } - if receipt.is_some() { - anyhow::bail!( - "--receipt is supported only for Devbox and Flox export" - ); + new = ''' EnvCmd::Export { + manager, + plan, + output, + check, + write, + receipt, + json, + } => match manager { + EnvironmentExportManagerArg::Mise => { + if receipt.is_some() { + anyhow::bail!("`--receipt` is only supported for Devbox and Flox export"); + } + environment::export_mise( + &cwd, + &plan, + output.as_deref(), + check, + write, + json, + ) } - let Some(plan) = plan else { - anyhow::bail!("mise export requires --plan PATH"); - }; - let output = output.unwrap_or_else(|| ".mise.toml".into()); - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvironmentExportManagerArg::Devbox - | EnvironmentExportManagerArg::Flox => { - if check || write { - anyhow::bail!( - "--check and --write are supported only for mise export" - ); + EnvironmentExportManagerArg::Devbox => { + if check || write { + anyhow::bail!("`--check` and `--write` are only supported for mise export"); + } + environment_export_cli::execute( + &cwd, + EnvironmentExportAction::Devbox, + Some(plan), + output, + receipt, + json, + ) } - let manager = match manager { - EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, - EnvironmentExportManagerArg::Flox => ExportManager::Flox, - EnvironmentExportManagerArg::Mise => unreachable!(), - }; - environment_export_cli::execute( - &cwd, - manager, - ExportOptions { - plan, + EnvironmentExportManagerArg::Flox => { + if check || write { + anyhow::bail!("`--check` and `--write` are only supported for mise export"); + } + environment_export_cli::execute( + &cwd, + EnvironmentExportAction::Flox, + Some(plan), output, receipt, json, - }, - ) - } - }, + ) + } + }, ''' - if old_dispatch not in text: + if old not in text: raise SystemExit('main environment export dispatch anchor not found') - main.write_text(text.replace(old_dispatch, new_dispatch, 1)) + main.write_text(text.replace(old, new, 1)) + PY + + - name: Update flags and documentation contracts + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path flags = Path('.cli-flags.toml') text = flags.read_text() - text = text.replace('default = ".mise.toml"\n', '', 1) - output_block = '''[commands.env.commands.export.flags.env_output] - env = "ZED_PKG_ENV_OUTPUT" - aliases = ["output"] - type = "string" - help = "Project-local manager export path." + if '[commands.env.commands.export.flags.receipt]' not in text: + marker = '''[commands.env.commands.export.flags.json] + env = "ZED_PKG_ENV_JSON" ''' - receipt_block = output_block + ''' - [commands.env.commands.export.flags.env_receipt] + contract = '''[commands.env.commands.export.flags.receipt] env = "ZED_PKG_ENV_RECEIPT" aliases = ["receipt"] type = "string" - help = "Project-local Zed-owned Devbox/Flox export receipt." + help = "Project-local receipt path for Devbox or Flox export." + + [commands.env.commands.export.flags.json] + env = "ZED_PKG_ENV_JSON" ''' - if output_block not in text: - raise SystemExit('flags registry output block not found') - flags.write_text(text.replace(output_block, receipt_block, 1)) + if marker not in text: + raise SystemExit('flags receipt insertion anchor not found') + text = text.replace(marker, contract, 1) + flags.write_text(text) readme = Path('README.md') text = readme.read_text() - mise_row = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' - rows = mise_row + '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Generate deterministic Devbox configuration plus a Zed-owned provenance receipt without invoking Devbox or Nix |\n| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Generate deterministic Flox manifest configuration plus a Zed-owned provenance receipt without invoking Flox or Nix |\n' - if 'zed env export devbox' not in text: - if mise_row not in text: - raise SystemExit('README mise export row not found') - text = text.replace(mise_row, rows, 1) + mise = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' + rows = '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox configuration and a Zed-owned provenance receipt without invoking Devbox |\n| `zed env export flox [--plan PATH] [--output manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned provenance receipt without invoking Flox |\n' + if rows not in text: + if mise not in text: + raise SystemExit('README mise export anchor not found') + text = text.replace(mise, mise + rows, 1) readme.write_text(text) - - module = Path('src/nix_environment_export.rs') - text = module.read_text() - text = text.replace('pub fn generate_devbox(', 'pub(crate) fn generate_devbox(', 1) - text = text.replace('pub fn generate_flox(', 'pub(crate) fn generate_flox(', 1) - module.write_text(text) PY - - name: Format, test, and lint the canonical route + - name: Validate canonical and staged parity run: | set -euo pipefail cargo fmt --all cargo fmt --all --check - cargo test --locked --lib environment_export_cli::tests cargo test --locked --lib nix_environment_export::tests cargo test --locked --test nix_environment_export_cli cargo test --locked --test nix_environment_export_main_cli - cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes 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-env-export \ - --test nix_environment_export_cli \ - --test nix_environment_export_main_cli -- -D warnings + --test nix_environment_export_cli --test nix_environment_export_main_cli -- -D warnings - name: Commit ordinary typed integration files run: | set -euo pipefail + rm .github/workflows/materialize-canonical-devbox-flox.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/cli.rs src/main.rs src/nix_environment_export.rs .cli-flags.toml README.md + git add src/cli.rs src/main.rs .cli-flags.toml README.md \ + .github/workflows/materialize-canonical-devbox-flox.yml git diff --cached --check - git commit -m 'feat(DEN-2487): route Devbox and Flox through zed env export' + git commit -m 'feat(DEN-2487): expose Devbox and Flox through zed env export' git push origin HEAD:feat/den-2487-canonical-devbox-flox-export From c1563f43094e95aec6443ee79d573e220c0706cb Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 13:12:26 -0500 Subject: [PATCH 09/13] chore(DEN-2487): apply typed routing to final exporter base --- .github/workflows/materialize-canonical-devbox-flox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/materialize-canonical-devbox-flox.yml b/.github/workflows/materialize-canonical-devbox-flox.yml index f9517a16..098b0749 100644 --- a/.github/workflows/materialize-canonical-devbox-flox.yml +++ b/.github/workflows/materialize-canonical-devbox-flox.yml @@ -1,4 +1,4 @@ -# Re-run after synchronizing the current deterministic mise-export base. +# Apply once against deterministic mise-export head e08910e86880fdc7f729daebec28b8f79c477508. name: materialize canonical Devbox and Flox routing on: From 1079c51427cc7f32188f4c26603b2ebb164d3e57 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 15:56:04 -0500 Subject: [PATCH 10/13] style(DEN-2487): format canonical export CLI assertions --- tests/nix_environment_export_main_cli.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/nix_environment_export_main_cli.rs b/tests/nix_environment_export_main_cli.rs index dbf0b2f8..2f59bcdd 100644 --- a/tests/nix_environment_export_main_cli.rs +++ b/tests/nix_environment_export_main_cli.rs @@ -182,8 +182,10 @@ fn manager_specific_options_fail_closed() { let devbox_check = run_main(root.path(), "devbox", &["--check"]); assert!(!devbox_check.status.success()); - assert!(String::from_utf8_lossy(&devbox_check.stderr) - .contains("--check and --write are supported only for mise export")); + assert!( + String::from_utf8_lossy(&devbox_check.stderr) + .contains("--check and --write are supported only for mise export") + ); let mise_receipt = run_main( root.path(), @@ -196,8 +198,10 @@ fn manager_specific_options_fail_closed() { ], ); assert!(!mise_receipt.status.success()); - assert!(String::from_utf8_lossy(&mise_receipt.stderr) - .contains("--receipt is supported only for Devbox and Flox export")); + assert!( + String::from_utf8_lossy(&mise_receipt.stderr) + .contains("--receipt is supported only for Devbox and Flox export") + ); let asdf = run_main(root.path(), "asdf", &[]); assert!(!asdf.status.success()); From eafe447f7d0bb7fc937899e56b820fa63b1e83b1 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:06:55 -0500 Subject: [PATCH 11/13] ci(DEN-2487): materialize durable canonical manager routing --- .../materialize-canonical-devbox-flox.yml | 324 ++++++++++++------ 1 file changed, 224 insertions(+), 100 deletions(-) diff --git a/.github/workflows/materialize-canonical-devbox-flox.yml b/.github/workflows/materialize-canonical-devbox-flox.yml index 098b0749..a737ec45 100644 --- a/.github/workflows/materialize-canonical-devbox-flox.yml +++ b/.github/workflows/materialize-canonical-devbox-flox.yml @@ -1,4 +1,3 @@ -# Apply once against deterministic mise-export head e08910e86880fdc7f729daebec28b8f79c477508. name: materialize canonical Devbox and Flox routing on: @@ -23,9 +22,9 @@ jobs: materialize: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-24.04 - timeout-minutes: 35 + timeout-minutes: 40 steps: - - name: Check out exact stacked branch + - name: Check out exact product branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: feat/den-2487-canonical-devbox-flox-export @@ -38,7 +37,7 @@ jobs: rustup toolchain install stable --profile minimal --component rustfmt,clippy rustup default stable - - name: Add typed canonical manager routing + - name: Add durable typed canonical manager routing run: | set -euo pipefail python3 - <<'PY' @@ -47,7 +46,7 @@ jobs: cli = Path('src/cli.rs') text = cli.read_text() - manager_anchor = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] + old = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum EnvironmentManagerArg { /// Import, verify, or export project-local mise configuration. Mise, @@ -55,7 +54,7 @@ jobs: Asdf, } ''' - manager_replacement = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] + new = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum EnvironmentManagerArg { /// Import or verify project-local mise configuration. Mise, @@ -73,163 +72,287 @@ jobs: Flox, } ''' - if 'pub enum EnvironmentExportManagerArg {' not in text: - if manager_anchor not in text: - raise SystemExit('environment manager enum anchor not found') - text = text.replace(manager_anchor, manager_replacement, 1) + if old not in text: + raise SystemExit('environment manager enum anchor not found') + text = text.replace(old, new, 1) - export_anchor = ''' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. - Export { - ''' - export_replacement = ''' /// Export a schema-v2 EnvironmentPlan to a supported deterministic manager format. + old = ''' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. Export { - ''' - if export_anchor in text: - text = text.replace(export_anchor, export_replacement, 1) - - text = text.replace( - 'manager: EnvironmentManagerArg,\n /// Project-local schema-v2 environment plan.', - 'manager: EnvironmentExportManagerArg,\n /// Project-local schema-v2 environment plan.', - 1, - ) - - if 'receipt: Option' not in text: - marker = ''' /// Emit one stable JSON result object. + #[arg(value_enum)] + manager: EnvironmentManagerArg, + /// Project-local schema-v2 EnvironmentPlan (.toml or .json). + #[arg(long, env = "ZED_PKG_ENV_PLAN")] + plan: PathBuf, + /// Project-local mise output path. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] + output: PathBuf, + /// Verify that the output already equals the deterministic projection. + #[arg(long, env = "ZED_PKG_ENV_CHECK")] + check: bool, + /// Transactionally create/update a Zed-owned manager view. + #[arg(long, env = "ZED_PKG_ENV_WRITE")] + write: bool, + /// Emit a machine-readable export result. #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, ''' - replacement = ''' /// Optional project-local receipt path for Devbox/Flox exports. + new = ''' /// Export a schema-v2 EnvironmentPlan to deterministic manager configuration. + Export { + #[arg(value_enum)] + manager: EnvironmentExportManagerArg, + /// Project-local schema-v2 EnvironmentPlan (.toml or .json). Devbox/Flox default to `.zed/environment-plan.json`; mise requires this flag. + #[arg(long, env = "ZED_PKG_ENV_PLAN")] + plan: Option, + /// Project-local manager output path. Defaults are manager-specific. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] + output: Option, + /// Zed-owned deterministic receipt path for Devbox/Flox export. #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] receipt: Option, - /// Emit one stable JSON result object. + /// Verify that the mise output already equals the deterministic projection. + #[arg(long, env = "ZED_PKG_ENV_CHECK")] + check: bool, + /// Transactionally create/update a Zed-owned mise view. + #[arg(long, env = "ZED_PKG_ENV_WRITE")] + write: bool, + /// Emit a machine-readable export result. #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, ''' - if marker not in text: - raise SystemExit('environment export receipt anchor not found') - text = text.replace(marker, replacement, 1) - cli.write_text(text) + if old not in text: + raise SystemExit('EnvCmd::Export anchor not found') + text = text.replace(old, new, 1) + + old = ''' fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { + let cli = Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--output", + ".mise.toml", + "--check", + "--json", + ]) + .unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + + assert!(matches!( + Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .unwrap() + .cmd, + Cmd::Env { + cmd: EnvCmd::Export { + check: true, + write: true, + .. + } + } + )); + } + ''' + new = ''' fn environment_export_is_typed_and_preserves_manager_boundaries() { + for manager in ["mise", "devbox", "flox"] { + let mut args = vec!["zed", "env", "export", manager, "--json"]; + if manager == "mise" { + args.extend(["--plan", "zed-env.toml", "--output", ".mise.toml", "--check"]); + } else { + args.extend(["--receipt", ".zed/receipt.json"]); + } + let cli = Cli::try_parse_from(args).unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + } + + assert!(matches!( + Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .unwrap() + .cmd, + Cmd::Env { + cmd: EnvCmd::Export { + check: true, + write: true, + .. + } + } + )); + } + ''' + if old not in text: + raise SystemExit('typed export test anchor not found') + cli.write_text(text.replace(old, new, 1)) main = Path('src/main.rs') text = main.read_text() - if 'EnvironmentExportManagerArg' not in text: - text = text.replace( - 'EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd,', - 'EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd,', - 1, - ) - if 'use zed_cli::environment_export_cli' not in text: - marker = 'use zed_cli::environment;\n' - text = text.replace( - marker, - marker + 'use zed_cli::environment_export_cli::{self, EnvironmentExportAction};\n', - 1, - ) + old = '''use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, TaskCmd, + }; + ''' + new = '''use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, + ReleaseCmd, StoreCmd, TaskCmd, + }; + ''' + if old not in text: + raise SystemExit('main CLI import anchor not found') + text = text.replace(old, new, 1) + old = 'use zed_cli::environment;\n' + new = '''use zed_cli::environment; + use zed_cli::environment_export_cli::{self, ExportOptions}; + ''' + if old not in text: + raise SystemExit('main environment import anchor not found') + text = text.replace(old, new, 1) + old = 'use zed_cli::nix_bundle_write;\n' + new = '''use zed_cli::nix_bundle_write; + use zed_cli::nix_environment_export::ExportManager; + ''' + if old not in text: + raise SystemExit('main Nix import anchor not found') + text = text.replace(old, new, 1) old = ''' EnvCmd::Export { - manager: _, + manager: EnvironmentManagerArg::Mise, plan, output, check, write, json, - } => environment::export_mise(&cwd, &plan, output.as_deref(), check, write, json), + } => { + if check && write { + anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) + } + EnvCmd::Export { + manager: EnvironmentManagerArg::Asdf, + .. + } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), ''' new = ''' EnvCmd::Export { manager, plan, output, + receipt, check, write, - receipt, json, } => match manager { EnvironmentExportManagerArg::Mise => { - if receipt.is_some() { - anyhow::bail!("`--receipt` is only supported for Devbox and Flox export"); + if check && write { + anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); } - environment::export_mise( - &cwd, - &plan, - output.as_deref(), - check, - write, - json, - ) - } - EnvironmentExportManagerArg::Devbox => { - if check || write { - anyhow::bail!("`--check` and `--write` are only supported for mise export"); + if receipt.is_some() { + anyhow::bail!("--receipt is supported only for Devbox and Flox export"); } - environment_export_cli::execute( - &cwd, - EnvironmentExportAction::Devbox, - Some(plan), - output, - receipt, - json, - ) + let Some(plan) = plan else { + anyhow::bail!("mise export requires --plan PATH"); + }; + let output = output.unwrap_or_else(|| PathBuf::from(".mise.toml")); + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) } - EnvironmentExportManagerArg::Flox => { + EnvironmentExportManagerArg::Devbox | EnvironmentExportManagerArg::Flox => { if check || write { - anyhow::bail!("`--check` and `--write` are only supported for mise export"); + anyhow::bail!("--check and --write are supported only for mise export"); } + let manager = match manager { + EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, + EnvironmentExportManagerArg::Flox => ExportManager::Flox, + EnvironmentExportManagerArg::Mise => unreachable!(), + }; environment_export_cli::execute( &cwd, - EnvironmentExportAction::Flox, - Some(plan), - output, - receipt, - json, + manager, + ExportOptions { + plan, + output, + receipt, + json, + }, ) } }, ''' if old not in text: - raise SystemExit('main environment export dispatch anchor not found') + raise SystemExit('main export dispatch anchor not found') main.write_text(text.replace(old, new, 1)) - PY - - - name: Update flags and documentation contracts - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path flags = Path('.cli-flags.toml') text = flags.read_text() - if '[commands.env.commands.export.flags.receipt]' not in text: - marker = '''[commands.env.commands.export.flags.json] - env = "ZED_PKG_ENV_JSON" + text = text.replace('default = ".mise.toml"\nhelp = "Project-local manager export path."', 'help = "Project-local manager export path; defaults are manager-specific."', 1) + marker = '''[commands.env.commands.export.flags.env_check] + env = "ZED_PKG_ENV_CHECK" ''' - contract = '''[commands.env.commands.export.flags.receipt] + receipt = '''[commands.env.commands.export.flags.env_receipt] env = "ZED_PKG_ENV_RECEIPT" aliases = ["receipt"] type = "string" - help = "Project-local receipt path for Devbox or Flox export." + help = "Zed-owned deterministic receipt path for Devbox or Flox export." - [commands.env.commands.export.flags.json] - env = "ZED_PKG_ENV_JSON" + [commands.env.commands.export.flags.env_check] + env = "ZED_PKG_ENV_CHECK" ''' + if '[commands.env.commands.export.flags.env_receipt]' not in text: if marker not in text: - raise SystemExit('flags receipt insertion anchor not found') - text = text.replace(marker, contract, 1) + raise SystemExit('flags receipt anchor not found') + text = text.replace(marker, receipt, 1) flags.write_text(text) readme = Path('README.md') text = readme.read_text() mise = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' - rows = '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox configuration and a Zed-owned provenance receipt without invoking Devbox |\n| `zed env export flox [--plan PATH] [--output manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned provenance receipt without invoking Flox |\n' + rows = '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox JSON and a Zed-owned receipt without invoking Devbox |\n| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned receipt without invoking Flox |\n' if rows not in text: if mise not in text: raise SystemExit('README mise export anchor not found') text = text.replace(mise, mise + rows, 1) readme.write_text(text) + + renderer = Path('src/nix_environment_export.rs') + text = renderer.read_text() + text = text.replace('pub fn generate_devbox(', 'fn generate_devbox(', 1) + text = text.replace('pub fn generate_flox(', 'fn generate_flox(', 1) + renderer.write_text(text) PY - - name: Validate canonical and staged parity + - name: Format, test, and lint all manager export surfaces run: | set -euo pipefail cargo fmt --all @@ -237,19 +360,20 @@ jobs: cargo test --locked --lib nix_environment_export::tests cargo test --locked --test nix_environment_export_cli cargo test --locked --test nix_environment_export_main_cli + cargo test --locked cli::tests::environment_export_is_typed_and_preserves_manager_boundaries 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-env-export \ --test nix_environment_export_cli --test nix_environment_export_main_cli -- -D warnings - - name: Commit ordinary typed integration files + - name: Commit ordinary product source and remove carrier run: | set -euo pipefail rm .github/workflows/materialize-canonical-devbox-flox.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/cli.rs src/main.rs .cli-flags.toml README.md \ - .github/workflows/materialize-canonical-devbox-flox.yml + git add src/cli.rs src/main.rs src/nix_environment_export.rs \ + .cli-flags.toml README.md .github/workflows/materialize-canonical-devbox-flox.yml git diff --cached --check - git commit -m 'feat(DEN-2487): expose Devbox and Flox through zed env export' + git commit -m 'feat(DEN-2487): route Devbox and Flox through zed env export' git push origin HEAD:feat/den-2487-canonical-devbox-flox-export From 19f4ff789abf7bced31b4cb925acfde1864c8746 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Sat, 8 Aug 2026 16:08:18 -0500 Subject: [PATCH 12/13] ci(DEN-2487): repair typed export routing with structural boundaries --- .../repair-canonical-devbox-flox-v2.yml | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 .github/workflows/repair-canonical-devbox-flox-v2.yml diff --git a/.github/workflows/repair-canonical-devbox-flox-v2.yml b/.github/workflows/repair-canonical-devbox-flox-v2.yml new file mode 100644 index 00000000..2beffc81 --- /dev/null +++ b/.github/workflows/repair-canonical-devbox-flox-v2.yml @@ -0,0 +1,330 @@ +name: repair canonical Devbox and Flox routing v2 + +on: + push: + branches: + - feat/den-2487-canonical-devbox-flox-export + paths: + - .github/workflows/repair-canonical-devbox-flox-v2.yml + +permissions: + contents: write + +concurrency: + group: repair-canonical-devbox-flox-v2 + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Check out exact product branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/den-2487-canonical-devbox-flox-export + persist-credentials: true + show-progress: false + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Materialize durable typed routing + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + cli = Path('src/cli.rs') + text = cli.read_text() + old = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] + pub enum EnvironmentManagerArg { + /// Import, verify, or export project-local mise configuration. + Mise, + /// Import or verify project-local asdf configuration and Zed-owned provenance. + Asdf, + } + ''' + new = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] + 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, Clone, Copy, PartialEq, Eq, ValueEnum)] + pub enum EnvironmentExportManagerArg { + /// Export deterministic mise TOML from a schema-v2 plan. + Mise, + /// Export deterministic Devbox JSON and a Zed-owned receipt. + Devbox, + /// Export deterministic Flox manifest TOML and a Zed-owned receipt. + Flox, + } + ''' + if old not in text: + raise SystemExit('environment manager enum anchor not found') + text = text.replace(old, new, 1) + + start = text.index(' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML.\n Export {') + end = text.index(' /// Verify manager config/lock coverage', start) + export_block = ''' /// Export a schema-v2 EnvironmentPlan to deterministic manager configuration. + Export { + #[arg(value_enum)] + manager: EnvironmentExportManagerArg, + /// Project-local schema-v2 EnvironmentPlan. Devbox/Flox default to `.zed/environment-plan.json`; mise requires this flag. + #[arg(long, env = "ZED_PKG_ENV_PLAN")] + plan: Option, + /// Project-local manager output path. Defaults are manager-specific. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] + output: Option, + /// Zed-owned deterministic receipt path for Devbox/Flox export. + #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] + receipt: Option, + /// Verify that the mise output already equals the deterministic projection. + #[arg(long, env = "ZED_PKG_ENV_CHECK")] + check: bool, + /// Transactionally create/update a Zed-owned mise view. + #[arg(long, env = "ZED_PKG_ENV_WRITE")] + write: bool, + /// Emit a machine-readable export result. + #[arg(long, env = "ZED_PKG_ENV_JSON")] + json: bool, + }, + ''' + text = text[:start] + export_block + text[end:] + + old_test = ''' fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { + let cli = Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--output", + ".mise.toml", + "--check", + "--json", + ]) + .unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + + assert!(matches!( + Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .unwrap() + .cmd, + Cmd::Env { + cmd: EnvCmd::Export { + check: true, + write: true, + .. + } + } + )); + } + ''' + new_test = ''' fn environment_export_is_typed_and_preserves_manager_boundaries() { + for manager in ["mise", "devbox", "flox"] { + let mut args = vec!["zed", "env", "export", manager, "--json"]; + if manager == "mise" { + args.extend(["--plan", "zed-env.toml", "--output", ".mise.toml", "--check"]); + } else { + args.extend(["--receipt", ".zed/receipt.json"]); + } + let cli = Cli::try_parse_from(args).unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + } + + assert!(matches!( + Cli::try_parse_from([ + "zed", + "env", + "export", + "mise", + "--plan", + "zed-env.toml", + "--check", + "--write", + ]) + .unwrap() + .cmd, + Cmd::Env { + cmd: EnvCmd::Export { + check: true, + write: true, + .. + } + } + )); + } + ''' + if old_test not in text: + raise SystemExit('typed export test anchor not found') + cli.write_text(text.replace(old_test, new_test, 1)) + + main = Path('src/main.rs') + text = main.read_text() + old = '''use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, TaskCmd, + }; + ''' + new = '''use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, + ReleaseCmd, StoreCmd, TaskCmd, + }; + ''' + if old not in text: + raise SystemExit('main CLI import anchor not found') + text = text.replace(old, new, 1) + text = text.replace( + 'use zed_cli::environment;\n', + 'use zed_cli::environment;\nuse zed_cli::environment_export_cli::{self, ExportOptions};\n', + 1, + ) + text = text.replace( + 'use zed_cli::nix_bundle_write;\n', + 'use zed_cli::nix_bundle_write;\nuse zed_cli::nix_environment_export::ExportManager;\n', + 1, + ) + start = text.index(' EnvCmd::Export {') + end = text.index(' EnvCmd::Verify {', start) + dispatch = ''' EnvCmd::Export { + manager, + plan, + output, + receipt, + check, + write, + json, + } => match manager { + EnvironmentExportManagerArg::Mise => { + if check && write { + anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } + if receipt.is_some() { + anyhow::bail!("--receipt is supported only for Devbox and Flox export"); + } + let Some(plan) = plan else { + anyhow::bail!("mise export requires --plan PATH"); + }; + let output = output.unwrap_or_else(|| std::path::PathBuf::from(".mise.toml")); + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) + } + EnvironmentExportManagerArg::Devbox | EnvironmentExportManagerArg::Flox => { + if check || write { + anyhow::bail!("--check and --write are supported only for mise export"); + } + let manager = match manager { + EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, + EnvironmentExportManagerArg::Flox => ExportManager::Flox, + EnvironmentExportManagerArg::Mise => unreachable!(), + }; + environment_export_cli::execute( + &cwd, + manager, + ExportOptions { + plan, + output, + receipt, + json, + }, + ) + } + }, + ''' + main.write_text(text[:start] + dispatch + text[end:]) + + flags = Path('.cli-flags.toml') + text = flags.read_text() + text = text.replace( + 'default = ".mise.toml"\nhelp = "Project-local manager export path."', + 'help = "Project-local manager export path; defaults are manager-specific."', + 1, + ) + marker = '[commands.env.commands.export.flags.env_check]\nenv = "ZED_PKG_ENV_CHECK"\n' + receipt = '''[commands.env.commands.export.flags.env_receipt] + env = "ZED_PKG_ENV_RECEIPT" + aliases = ["receipt"] + type = "string" + help = "Zed-owned deterministic receipt path for Devbox or Flox export." + + [commands.env.commands.export.flags.env_check] + env = "ZED_PKG_ENV_CHECK" + ''' + if marker not in text: + raise SystemExit('flags receipt anchor not found') + flags.write_text(text.replace(marker, receipt, 1)) + + readme = Path('README.md') + text = readme.read_text() + mise = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' + rows = '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox JSON and a Zed-owned receipt without invoking Devbox |\n| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned receipt without invoking Flox |\n' + if rows not in text: + if mise not in text: + raise SystemExit('README mise export anchor not found') + text = text.replace(mise, mise + rows, 1) + readme.write_text(text) + + renderer = Path('src/nix_environment_export.rs') + text = renderer.read_text() + text = text.replace('pub fn generate_devbox(', 'fn generate_devbox(', 1) + text = text.replace('pub fn generate_flox(', 'fn generate_flox(', 1) + renderer.write_text(text) + PY + + - name: Format, test, and lint all manager export surfaces + run: | + set -euo pipefail + cargo fmt --all + cargo fmt --all --check + cargo test --locked --lib nix_environment_export::tests + cargo test --locked --test nix_environment_export_cli + cargo test --locked --test nix_environment_export_main_cli + cargo test --locked cli::tests::environment_export_is_typed_and_preserves_manager_boundaries + 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-env-export \ + --test nix_environment_export_cli --test nix_environment_export_main_cli -- -D warnings + + - name: Commit ordinary product source and remove carriers + run: | + set -euo pipefail + rm -f \ + .github/workflows/materialize-canonical-devbox-flox.yml \ + .github/workflows/repair-canonical-devbox-flox-v2.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/cli.rs src/main.rs src/nix_environment_export.rs \ + .cli-flags.toml README.md \ + .github/workflows/materialize-canonical-devbox-flox.yml \ + .github/workflows/repair-canonical-devbox-flox-v2.yml + git diff --cached --check + git commit -m 'feat(DEN-2487): route Devbox and Flox through zed env export' + git push origin HEAD:feat/den-2487-canonical-devbox-flox-export From 137e760ae2835492b537a748013371b4a3abc37f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:10:37 +0000 Subject: [PATCH 13/13] feat(DEN-2487): route Devbox and Flox through zed env export --- .cli-flags.toml | 9 +- .../materialize-canonical-devbox-flox.yml | 379 ------------------ .../repair-canonical-devbox-flox-v2.yml | 330 --------------- README.md | 2 + src/cli.rs | 65 +-- src/main.rs | 69 +++- src/nix_environment_export.rs | 4 +- 7 files changed, 101 insertions(+), 757 deletions(-) delete mode 100644 .github/workflows/materialize-canonical-devbox-flox.yml delete mode 100644 .github/workflows/repair-canonical-devbox-flox-v2.yml diff --git a/.cli-flags.toml b/.cli-flags.toml index c7acc3a4..3cc39e98 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -314,8 +314,13 @@ help = "Project-local schema-v2 environment plan path." env = "ZED_PKG_ENV_OUTPUT" aliases = ["output"] type = "string" -default = ".mise.toml" -help = "Project-local manager export path." +help = "Project-local manager export path; defaults are manager-specific." + +[commands.env.commands.export.flags.env_receipt] +env = "ZED_PKG_ENV_RECEIPT" +aliases = ["receipt"] +type = "string" +help = "Zed-owned deterministic receipt path for Devbox or Flox export." [commands.env.commands.export.flags.env_check] env = "ZED_PKG_ENV_CHECK" diff --git a/.github/workflows/materialize-canonical-devbox-flox.yml b/.github/workflows/materialize-canonical-devbox-flox.yml deleted file mode 100644 index a737ec45..00000000 --- a/.github/workflows/materialize-canonical-devbox-flox.yml +++ /dev/null @@ -1,379 +0,0 @@ -name: materialize canonical Devbox and Flox routing - -on: - push: - branches: - - feat/den-2487-canonical-devbox-flox-export - paths: - - .github/workflows/materialize-canonical-devbox-flox.yml - -permissions: - contents: write - -concurrency: - group: materialize-canonical-devbox-flox - cancel-in-progress: false - -defaults: - run: - shell: bash - -jobs: - materialize: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 40 - steps: - - name: Check out exact product branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/den-2487-canonical-devbox-flox-export - persist-credentials: true - show-progress: false - - - name: Install Rust quality components - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Add durable typed canonical manager routing - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - cli = Path('src/cli.rs') - text = cli.read_text() - - old = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] - pub enum EnvironmentManagerArg { - /// Import, verify, or export project-local mise configuration. - Mise, - /// Import or verify project-local asdf configuration and Zed-owned provenance. - Asdf, - } - ''' - new = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] - 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, Clone, Copy, PartialEq, Eq, ValueEnum)] - pub enum EnvironmentExportManagerArg { - /// Export deterministic mise TOML from a schema-v2 plan. - Mise, - /// Export deterministic Devbox JSON and a Zed-owned receipt. - Devbox, - /// Export deterministic Flox manifest TOML and a Zed-owned receipt. - Flox, - } - ''' - if old not in text: - raise SystemExit('environment manager enum anchor not found') - text = text.replace(old, new, 1) - - old = ''' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. - Export { - #[arg(value_enum)] - manager: EnvironmentManagerArg, - /// Project-local schema-v2 EnvironmentPlan (.toml or .json). - #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: PathBuf, - /// Project-local mise output path. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] - output: PathBuf, - /// Verify that the output already equals the deterministic projection. - #[arg(long, env = "ZED_PKG_ENV_CHECK")] - check: bool, - /// Transactionally create/update a Zed-owned manager view. - #[arg(long, env = "ZED_PKG_ENV_WRITE")] - write: bool, - /// Emit a machine-readable export result. - #[arg(long, env = "ZED_PKG_ENV_JSON")] - json: bool, - }, - ''' - new = ''' /// Export a schema-v2 EnvironmentPlan to deterministic manager configuration. - Export { - #[arg(value_enum)] - manager: EnvironmentExportManagerArg, - /// Project-local schema-v2 EnvironmentPlan (.toml or .json). Devbox/Flox default to `.zed/environment-plan.json`; mise requires this flag. - #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: Option, - /// Project-local manager output path. Defaults are manager-specific. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] - output: Option, - /// Zed-owned deterministic receipt path for Devbox/Flox export. - #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] - receipt: Option, - /// Verify that the mise output already equals the deterministic projection. - #[arg(long, env = "ZED_PKG_ENV_CHECK")] - check: bool, - /// Transactionally create/update a Zed-owned mise view. - #[arg(long, env = "ZED_PKG_ENV_WRITE")] - write: bool, - /// Emit a machine-readable export result. - #[arg(long, env = "ZED_PKG_ENV_JSON")] - json: bool, - }, - ''' - if old not in text: - raise SystemExit('EnvCmd::Export anchor not found') - text = text.replace(old, new, 1) - - old = ''' fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { - let cli = Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--output", - ".mise.toml", - "--check", - "--json", - ]) - .unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); - - assert!(matches!( - Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--check", - "--write", - ]) - .unwrap() - .cmd, - Cmd::Env { - cmd: EnvCmd::Export { - check: true, - write: true, - .. - } - } - )); - } - ''' - new = ''' fn environment_export_is_typed_and_preserves_manager_boundaries() { - for manager in ["mise", "devbox", "flox"] { - let mut args = vec!["zed", "env", "export", manager, "--json"]; - if manager == "mise" { - args.extend(["--plan", "zed-env.toml", "--output", ".mise.toml", "--check"]); - } else { - args.extend(["--receipt", ".zed/receipt.json"]); - } - let cli = Cli::try_parse_from(args).unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); - } - - assert!(matches!( - Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--check", - "--write", - ]) - .unwrap() - .cmd, - Cmd::Env { - cmd: EnvCmd::Export { - check: true, - write: true, - .. - } - } - )); - } - ''' - if old not in text: - raise SystemExit('typed export 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::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, TaskCmd, - }; - ''' - new = '''use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, - ReleaseCmd, StoreCmd, TaskCmd, - }; - ''' - if old not in text: - raise SystemExit('main CLI import anchor not found') - text = text.replace(old, new, 1) - old = 'use zed_cli::environment;\n' - new = '''use zed_cli::environment; - use zed_cli::environment_export_cli::{self, ExportOptions}; - ''' - if old not in text: - raise SystemExit('main environment import anchor not found') - text = text.replace(old, new, 1) - old = 'use zed_cli::nix_bundle_write;\n' - new = '''use zed_cli::nix_bundle_write; - use zed_cli::nix_environment_export::ExportManager; - ''' - if old not in text: - raise SystemExit('main Nix import anchor not found') - text = text.replace(old, new, 1) - - old = ''' EnvCmd::Export { - manager: EnvironmentManagerArg::Mise, - plan, - output, - check, - write, - json, - } => { - if check && write { - anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); - } - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvCmd::Export { - manager: EnvironmentManagerArg::Asdf, - .. - } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), - ''' - new = ''' EnvCmd::Export { - manager, - plan, - output, - receipt, - check, - write, - json, - } => match manager { - EnvironmentExportManagerArg::Mise => { - if check && write { - anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); - } - if receipt.is_some() { - anyhow::bail!("--receipt is supported only for Devbox and Flox export"); - } - let Some(plan) = plan else { - anyhow::bail!("mise export requires --plan PATH"); - }; - let output = output.unwrap_or_else(|| PathBuf::from(".mise.toml")); - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvironmentExportManagerArg::Devbox | EnvironmentExportManagerArg::Flox => { - if check || write { - anyhow::bail!("--check and --write are supported only for mise export"); - } - let manager = match manager { - EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, - EnvironmentExportManagerArg::Flox => ExportManager::Flox, - EnvironmentExportManagerArg::Mise => unreachable!(), - }; - environment_export_cli::execute( - &cwd, - manager, - ExportOptions { - plan, - output, - receipt, - json, - }, - ) - } - }, - ''' - if old not in text: - raise SystemExit('main export dispatch anchor not found') - main.write_text(text.replace(old, new, 1)) - - flags = Path('.cli-flags.toml') - text = flags.read_text() - text = text.replace('default = ".mise.toml"\nhelp = "Project-local manager export path."', 'help = "Project-local manager export path; defaults are manager-specific."', 1) - marker = '''[commands.env.commands.export.flags.env_check] - env = "ZED_PKG_ENV_CHECK" - ''' - receipt = '''[commands.env.commands.export.flags.env_receipt] - env = "ZED_PKG_ENV_RECEIPT" - aliases = ["receipt"] - type = "string" - help = "Zed-owned deterministic receipt path for Devbox or Flox export." - - [commands.env.commands.export.flags.env_check] - env = "ZED_PKG_ENV_CHECK" - ''' - if '[commands.env.commands.export.flags.env_receipt]' not in text: - if marker not in text: - raise SystemExit('flags receipt anchor not found') - text = text.replace(marker, receipt, 1) - flags.write_text(text) - - readme = Path('README.md') - text = readme.read_text() - mise = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' - rows = '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox JSON and a Zed-owned receipt without invoking Devbox |\n| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned receipt without invoking Flox |\n' - if rows not in text: - if mise not in text: - raise SystemExit('README mise export anchor not found') - text = text.replace(mise, mise + rows, 1) - readme.write_text(text) - - renderer = Path('src/nix_environment_export.rs') - text = renderer.read_text() - text = text.replace('pub fn generate_devbox(', 'fn generate_devbox(', 1) - text = text.replace('pub fn generate_flox(', 'fn generate_flox(', 1) - renderer.write_text(text) - PY - - - name: Format, test, and lint all manager export surfaces - run: | - set -euo pipefail - cargo fmt --all - cargo fmt --all --check - cargo test --locked --lib nix_environment_export::tests - cargo test --locked --test nix_environment_export_cli - cargo test --locked --test nix_environment_export_main_cli - cargo test --locked cli::tests::environment_export_is_typed_and_preserves_manager_boundaries - 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-env-export \ - --test nix_environment_export_cli --test nix_environment_export_main_cli -- -D warnings - - - name: Commit ordinary product source and remove carrier - run: | - set -euo pipefail - rm .github/workflows/materialize-canonical-devbox-flox.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/cli.rs src/main.rs src/nix_environment_export.rs \ - .cli-flags.toml README.md .github/workflows/materialize-canonical-devbox-flox.yml - git diff --cached --check - git commit -m 'feat(DEN-2487): route Devbox and Flox through zed env export' - git push origin HEAD:feat/den-2487-canonical-devbox-flox-export diff --git a/.github/workflows/repair-canonical-devbox-flox-v2.yml b/.github/workflows/repair-canonical-devbox-flox-v2.yml deleted file mode 100644 index 2beffc81..00000000 --- a/.github/workflows/repair-canonical-devbox-flox-v2.yml +++ /dev/null @@ -1,330 +0,0 @@ -name: repair canonical Devbox and Flox routing v2 - -on: - push: - branches: - - feat/den-2487-canonical-devbox-flox-export - paths: - - .github/workflows/repair-canonical-devbox-flox-v2.yml - -permissions: - contents: write - -concurrency: - group: repair-canonical-devbox-flox-v2 - cancel-in-progress: false - -defaults: - run: - shell: bash - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 40 - steps: - - name: Check out exact product branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/den-2487-canonical-devbox-flox-export - persist-credentials: true - show-progress: false - - - name: Install Rust quality components - run: | - set -euo pipefail - rustup toolchain install stable --profile minimal --component rustfmt,clippy - rustup default stable - - - name: Materialize durable typed routing - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - cli = Path('src/cli.rs') - text = cli.read_text() - old = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] - pub enum EnvironmentManagerArg { - /// Import, verify, or export project-local mise configuration. - Mise, - /// Import or verify project-local asdf configuration and Zed-owned provenance. - Asdf, - } - ''' - new = '''#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] - 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, Clone, Copy, PartialEq, Eq, ValueEnum)] - pub enum EnvironmentExportManagerArg { - /// Export deterministic mise TOML from a schema-v2 plan. - Mise, - /// Export deterministic Devbox JSON and a Zed-owned receipt. - Devbox, - /// Export deterministic Flox manifest TOML and a Zed-owned receipt. - Flox, - } - ''' - if old not in text: - raise SystemExit('environment manager enum anchor not found') - text = text.replace(old, new, 1) - - start = text.index(' /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML.\n Export {') - end = text.index(' /// Verify manager config/lock coverage', start) - export_block = ''' /// Export a schema-v2 EnvironmentPlan to deterministic manager configuration. - Export { - #[arg(value_enum)] - manager: EnvironmentExportManagerArg, - /// Project-local schema-v2 EnvironmentPlan. Devbox/Flox default to `.zed/environment-plan.json`; mise requires this flag. - #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: Option, - /// Project-local manager output path. Defaults are manager-specific. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] - output: Option, - /// Zed-owned deterministic receipt path for Devbox/Flox export. - #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] - receipt: Option, - /// Verify that the mise output already equals the deterministic projection. - #[arg(long, env = "ZED_PKG_ENV_CHECK")] - check: bool, - /// Transactionally create/update a Zed-owned mise view. - #[arg(long, env = "ZED_PKG_ENV_WRITE")] - write: bool, - /// Emit a machine-readable export result. - #[arg(long, env = "ZED_PKG_ENV_JSON")] - json: bool, - }, - ''' - text = text[:start] + export_block + text[end:] - - old_test = ''' fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { - let cli = Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--output", - ".mise.toml", - "--check", - "--json", - ]) - .unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); - - assert!(matches!( - Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--check", - "--write", - ]) - .unwrap() - .cmd, - Cmd::Env { - cmd: EnvCmd::Export { - check: true, - write: true, - .. - } - } - )); - } - ''' - new_test = ''' fn environment_export_is_typed_and_preserves_manager_boundaries() { - for manager in ["mise", "devbox", "flox"] { - let mut args = vec!["zed", "env", "export", manager, "--json"]; - if manager == "mise" { - args.extend(["--plan", "zed-env.toml", "--output", ".mise.toml", "--check"]); - } else { - args.extend(["--receipt", ".zed/receipt.json"]); - } - let cli = Cli::try_parse_from(args).unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); - } - - assert!(matches!( - Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--check", - "--write", - ]) - .unwrap() - .cmd, - Cmd::Env { - cmd: EnvCmd::Export { - check: true, - write: true, - .. - } - } - )); - } - ''' - if old_test not in text: - raise SystemExit('typed export test anchor not found') - cli.write_text(text.replace(old_test, new_test, 1)) - - main = Path('src/main.rs') - text = main.read_text() - old = '''use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, TaskCmd, - }; - ''' - new = '''use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, - ReleaseCmd, StoreCmd, TaskCmd, - }; - ''' - if old not in text: - raise SystemExit('main CLI import anchor not found') - text = text.replace(old, new, 1) - text = text.replace( - 'use zed_cli::environment;\n', - 'use zed_cli::environment;\nuse zed_cli::environment_export_cli::{self, ExportOptions};\n', - 1, - ) - text = text.replace( - 'use zed_cli::nix_bundle_write;\n', - 'use zed_cli::nix_bundle_write;\nuse zed_cli::nix_environment_export::ExportManager;\n', - 1, - ) - start = text.index(' EnvCmd::Export {') - end = text.index(' EnvCmd::Verify {', start) - dispatch = ''' EnvCmd::Export { - manager, - plan, - output, - receipt, - check, - write, - json, - } => match manager { - EnvironmentExportManagerArg::Mise => { - if check && write { - anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); - } - if receipt.is_some() { - anyhow::bail!("--receipt is supported only for Devbox and Flox export"); - } - let Some(plan) = plan else { - anyhow::bail!("mise export requires --plan PATH"); - }; - let output = output.unwrap_or_else(|| std::path::PathBuf::from(".mise.toml")); - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvironmentExportManagerArg::Devbox | EnvironmentExportManagerArg::Flox => { - if check || write { - anyhow::bail!("--check and --write are supported only for mise export"); - } - let manager = match manager { - EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, - EnvironmentExportManagerArg::Flox => ExportManager::Flox, - EnvironmentExportManagerArg::Mise => unreachable!(), - }; - environment_export_cli::execute( - &cwd, - manager, - ExportOptions { - plan, - output, - receipt, - json, - }, - ) - } - }, - ''' - main.write_text(text[:start] + dispatch + text[end:]) - - flags = Path('.cli-flags.toml') - text = flags.read_text() - text = text.replace( - 'default = ".mise.toml"\nhelp = "Project-local manager export path."', - 'help = "Project-local manager export path; defaults are manager-specific."', - 1, - ) - marker = '[commands.env.commands.export.flags.env_check]\nenv = "ZED_PKG_ENV_CHECK"\n' - receipt = '''[commands.env.commands.export.flags.env_receipt] - env = "ZED_PKG_ENV_RECEIPT" - aliases = ["receipt"] - type = "string" - help = "Zed-owned deterministic receipt path for Devbox or Flox export." - - [commands.env.commands.export.flags.env_check] - env = "ZED_PKG_ENV_CHECK" - ''' - if marker not in text: - raise SystemExit('flags receipt anchor not found') - flags.write_text(text.replace(marker, receipt, 1)) - - readme = Path('README.md') - text = readme.read_text() - mise = '| `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code |\n' - rows = '| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox JSON and a Zed-owned receipt without invoking Devbox |\n| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned receipt without invoking Flox |\n' - if rows not in text: - if mise not in text: - raise SystemExit('README mise export anchor not found') - text = text.replace(mise, mise + rows, 1) - readme.write_text(text) - - renderer = Path('src/nix_environment_export.rs') - text = renderer.read_text() - text = text.replace('pub fn generate_devbox(', 'fn generate_devbox(', 1) - text = text.replace('pub fn generate_flox(', 'fn generate_flox(', 1) - renderer.write_text(text) - PY - - - name: Format, test, and lint all manager export surfaces - run: | - set -euo pipefail - cargo fmt --all - cargo fmt --all --check - cargo test --locked --lib nix_environment_export::tests - cargo test --locked --test nix_environment_export_cli - cargo test --locked --test nix_environment_export_main_cli - cargo test --locked cli::tests::environment_export_is_typed_and_preserves_manager_boundaries - 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-env-export \ - --test nix_environment_export_cli --test nix_environment_export_main_cli -- -D warnings - - - name: Commit ordinary product source and remove carriers - run: | - set -euo pipefail - rm -f \ - .github/workflows/materialize-canonical-devbox-flox.yml \ - .github/workflows/repair-canonical-devbox-flox-v2.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/cli.rs src/main.rs src/nix_environment_export.rs \ - .cli-flags.toml README.md \ - .github/workflows/materialize-canonical-devbox-flox.yml \ - .github/workflows/repair-canonical-devbox-flox-v2.yml - git diff --cached --check - git commit -m 'feat(DEN-2487): route Devbox and Flox through zed env export' - git push origin HEAD:feat/den-2487-canonical-devbox-flox-export diff --git a/README.md b/README.md index c28f9986..5f9cf709 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ registry hosts both on S3/Cloudflare R2. | `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 export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code | +| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox JSON and a Zed-owned receipt without invoking Devbox | +| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned receipt without invoking Flox | | `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 task list\|info\|graph\|run ...` | Use the shared schema-v2 runtime to discover, inspect, graph, dry-run, execute, confirm, parallelize, and content-cache project tasks; `zed-task` remains a compatibility binary | diff --git a/src/cli.rs b/src/cli.rs index 28c07731..94504a88 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -162,12 +162,22 @@ impl From for clap_complete::Shell { #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum EnvironmentManagerArg { - /// Import, verify, or export project-local mise configuration. + /// Import or verify project-local mise configuration. Mise, /// Import or verify project-local asdf configuration and Zed-owned provenance. Asdf, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum EnvironmentExportManagerArg { + /// Export deterministic mise TOML from a schema-v2 plan. + Mise, + /// Export deterministic Devbox JSON and a Zed-owned receipt. + Devbox, + /// Export deterministic Flox manifest TOML and a Zed-owned receipt. + Flox, +} + #[derive(Debug, Subcommand)] pub enum Cmd { /// Create a .zpkg.toml manifest in the current directory @@ -443,20 +453,23 @@ pub enum EnvCmd { #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, - /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. + /// Export a schema-v2 EnvironmentPlan to deterministic manager configuration. Export { #[arg(value_enum)] - manager: EnvironmentManagerArg, - /// Project-local schema-v2 EnvironmentPlan (.toml or .json). + manager: EnvironmentExportManagerArg, + /// Project-local schema-v2 EnvironmentPlan. Devbox/Flox default to `.zed/environment-plan.json`; mise requires this flag. #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: PathBuf, - /// Project-local mise output path. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] - output: PathBuf, - /// Verify that the output already equals the deterministic projection. + plan: Option, + /// Project-local manager output path. Defaults are manager-specific. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] + output: Option, + /// Zed-owned deterministic receipt path for Devbox/Flox export. + #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] + receipt: Option, + /// Verify that the mise output already equals the deterministic projection. #[arg(long, env = "ZED_PKG_ENV_CHECK")] check: bool, - /// Transactionally create/update a Zed-owned manager view. + /// Transactionally create/update a Zed-owned mise view. #[arg(long, env = "ZED_PKG_ENV_WRITE")] write: bool, /// Emit a machine-readable export result. @@ -763,21 +776,23 @@ mod tests { } #[test] - fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { - let cli = Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--output", - ".mise.toml", - "--check", - "--json", - ]) - .unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); + fn environment_export_is_typed_and_preserves_manager_boundaries() { + for manager in ["mise", "devbox", "flox"] { + let mut args = vec!["zed", "env", "export", manager, "--json"]; + if manager == "mise" { + args.extend([ + "--plan", + "zed-env.toml", + "--output", + ".mise.toml", + "--check", + ]); + } else { + args.extend(["--receipt", ".zed/receipt.json"]); + } + let cli = Cli::try_parse_from(args).unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + } assert!(matches!( Cli::try_parse_from([ diff --git a/src/main.rs b/src/main.rs index 53bbad23..0377b34d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,18 +4,21 @@ use zed_cli::asdf_environment; use zed_cli::auth; use zed_cli::cli::EnvCmd; use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, TaskCmd, + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, + ReleaseCmd, StoreCmd, TaskCmd, }; use zed_cli::completion; use zed_cli::config::Config; use zed_cli::dev; use zed_cli::environment; +use zed_cli::environment_export_cli::{self, ExportOptions}; use zed_cli::fetch; use zed_cli::git_submodules as submodules; use zed_cli::global; use zed_cli::managed_install; use zed_cli::mise_export::{self, MiseExportMode}; use zed_cli::nix_bundle_write; +use zed_cli::nix_environment_export::ExportManager; use zed_cli::nix_export_plan; use zed_cli::ops; use zed_cli::preflight; @@ -237,30 +240,58 @@ fn run(cli: Cli) -> anyhow::Result<()> { } }, EnvCmd::Export { - manager: EnvironmentManagerArg::Mise, + manager, plan, output, + receipt, check, write, json, - } => { - if check && write { - anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } => match manager { + EnvironmentExportManagerArg::Mise => { + if check && write { + anyhow::bail!( + "the arguments '--check' and '--write' cannot be used together" + ); + } + if receipt.is_some() { + anyhow::bail!("--receipt is supported only for Devbox and Flox export"); + } + let Some(plan) = plan else { + anyhow::bail!("mise export requires --plan PATH"); + }; + let output = output.unwrap_or_else(|| std::path::PathBuf::from(".mise.toml")); + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) } - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvCmd::Export { - manager: EnvironmentManagerArg::Asdf, - .. - } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), + EnvironmentExportManagerArg::Devbox | EnvironmentExportManagerArg::Flox => { + if check || write { + anyhow::bail!("--check and --write are supported only for mise export"); + } + let manager = match manager { + EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, + EnvironmentExportManagerArg::Flox => ExportManager::Flox, + EnvironmentExportManagerArg::Mise => unreachable!(), + }; + environment_export_cli::execute( + &cwd, + manager, + ExportOptions { + plan, + output, + receipt, + json, + }, + ) + } + }, EnvCmd::Verify { manager, config, diff --git a/src/nix_environment_export.rs b/src/nix_environment_export.rs index 7cdcfe1a..48c6cbec 100644 --- a/src/nix_environment_export.rs +++ b/src/nix_environment_export.rs @@ -262,7 +262,7 @@ pub fn export_environment( }) } -pub fn generate_devbox(packages: &[ManagerPackage]) -> Result> { +fn generate_devbox(packages: &[ManagerPackage]) -> Result> { let mut output_packages = BTreeMap::new(); for package in packages { let candidate = DevboxPackage { @@ -292,7 +292,7 @@ pub fn generate_devbox(packages: &[ManagerPackage]) -> Result> { Ok(bytes) } -pub fn generate_flox(plan: &EnvironmentPlan, packages: &[ManagerPackage]) -> Result> { +fn generate_flox(plan: &EnvironmentPlan, packages: &[ManagerPackage]) -> Result> { let mut install = BTreeMap::new(); for package in packages { validate_flox_alias(&package.logical_name)?;