Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/asdf-interop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ on:
paths:
- "src/asdf_environment.rs"
- "src/bin/zed-asdf.rs"
- "src/cli.rs"
- "src/main.rs"
- "src/lib.rs"
- "tests/asdf_environment_cli.rs"
- "tests/asdf_main_cli.rs"
- "docs/asdf-interop.md"
- "README.md"
- ".github/workflows/asdf-interop.yml"
- "Cargo.toml"
- "Cargo.lock"
Expand All @@ -16,8 +20,13 @@ on:
paths:
- "src/asdf_environment.rs"
- "src/bin/zed-asdf.rs"
- "src/cli.rs"
- "src/main.rs"
- "src/lib.rs"
- "tests/asdf_environment_cli.rs"
- "tests/asdf_main_cli.rs"
- "docs/asdf-interop.md"
- "README.md"
- ".github/workflows/asdf-interop.yml"
- "Cargo.toml"
- "Cargo.lock"
Expand Down Expand Up @@ -70,8 +79,12 @@ jobs:
set -euo pipefail
cargo test --locked --lib asdf_environment::tests
cargo test --locked --test asdf_environment_cli
cargo test --locked --test asdf_main_cli

- name: Clippy (strict)
run: |
set -euo pipefail
cargo clippy --locked --bin zed-asdf --test asdf_environment_cli -- -D warnings
cargo clippy --locked --bin zed --bin zed-asdf --test asdf_environment_cli --test asdf_main_cli -- -D warnings

- name: Prove import and verification did not mutate the checkout
run: git diff --exit-code
81 changes: 81 additions & 0 deletions .github/workflows/devbox-flox-export.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Devbox and Flox environment export

on:
pull_request:
paths:
- "src/nix_environment_export.rs"
- "src/bin/zed-env-export.rs"
- "tests/nix_environment_export_cli.rs"
- "docs/devbox-flox-interop.md"
- ".github/workflows/devbox-flox-export.yml"
- "Cargo.toml"
- "Cargo.lock"
push:
branches: [main]
paths:
- "src/nix_environment_export.rs"
- "src/bin/zed-env-export.rs"
- "tests/nix_environment_export_cli.rs"
- "docs/devbox-flox-interop.md"
- ".github/workflows/devbox-flox-export.yml"
- "Cargo.toml"
- "Cargo.lock"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: devbox-flox-export-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

defaults:
run:
shell: bash

jobs:
deterministic-export:
name: deterministic export / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os:
- ubuntu-24.04
- macos-15
- windows-2025
steps:
- name: Check out exact candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
persist-credentials: false
show-progress: false

- name: Install Rust quality components
run: |
set -euo pipefail
rustup toolchain install stable --profile minimal --component rustfmt,clippy
rustup default stable
rustc --version --verbose
cargo --version --verbose

- name: Format
run: cargo fmt --all --check

- name: Unit tests
run: cargo test --locked --bin zed-env-export

- name: Black-box CLI tests
run: cargo test --locked --test nix_environment_export_cli

- name: Clippy (strict)
run: |
set -euo pipefail
cargo clippy --locked --bin zed-env-export --test nix_environment_export_cli -- -D warnings

- name: Prove no generated files leaked into checkout
run: |
set -euo pipefail
test -z "$(git status --porcelain=v1 --untracked-files=all)"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ registry hosts both on S3/Cloudflare R2.
| `zed uninstall [<org>/<name> ...]` (`zed un`) | Transactionally remove all or selected materialized packages while retaining the manifest and lockfile for a frozen reinstall |
| `zed env import mise [--config PATH] [--lock PATH] [--frozen] [--json]` | Import the supported project-local mise tool/lock subset as the shared normalized `EnvironmentPlan`; never loads parent/global config or executes hooks |
| `zed env verify mise [--config PATH] [--lock PATH] --frozen [--json]` | Fail closed on missing lock coverage, drift, malformed checksums, unsupported semantics, or non-portable frozen state and report the stable plan digest |
| `zed env import asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] [--frozen] [--json]` | Import project-local asdf selections and optional immutable plugin/artifact provenance without invoking asdf or plugin code |
| `zed env verify asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] --frozen [--json]` | Verify exact asdf tool, plugin revision, artifact SHA-256, platform, and normalized plan identity without reading parent/global configuration |
| `zed find <query>` | Search the registry |
| `zed pack` | Build the pruned, deterministic `tar.gz` artifact |
| `zed release plan [--json]` | Print the credential-free Zed, native-registry, and forge-package release set derived from `.zpkg.toml` |
Expand Down
9 changes: 9 additions & 0 deletions docs/asdf-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ separate from the main `zed env` dispatcher until the shared mise command branch
lands; the reusable implementation lives in `zed_cli::asdf_environment` so the
later wiring step does not need to reimplement parsing or validation.

## Canonical `zed env` commands

```console
zed env import asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] [--frozen] [--json]
zed env verify asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] --frozen [--json]
```

The staged `zed-asdf` executable remains a focused adapter-test surface, but the supported user interface is the typed `zed env` dispatcher. Both routes call the same Rust module and preserve the same read-only, project-local, no-execution contract.

## Commands

```text
Expand Down
120 changes: 120 additions & 0 deletions docs/devbox-flox-interop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# 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 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.

## Commands

```text
zed-env-export devbox [--plan PATH] [--out PATH] [--receipt PATH] [--json]
zed-env-export flox [--plan PATH] [--out PATH] [--receipt PATH] [--json]
```

Defaults:

| 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.

## 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.

A tool uses:

```json
{
"requirement": "^22",
"resolved": "22.11.0",
"provider": "nixpkgs",
"backend": "nodejs_22"
}
```

A system package uses:

```json
{
"requirement": "2.47.0",
"resolved": "2.47.0",
"provider": "nixpkgs",
"package_ref": "gitFull"
}
```

The whole plan must pass `FrozenPortable` validation and set activation to
`frozen-install`. Resolved identities therefore cannot be absent, local, or
moving selectors. Package references contain no version suffix: versions and
platforms are emitted as separate fields.

## Devbox output

The generator writes the object form of `packages`, with one exact version and
an optional platform list per Nixpkgs package. The only shell hook is:

```text
zed install --frozen
```

The Zed dependency graph is never copied into the Devbox package list. Devbox
provides toolchains and system packages around the already locked Zed install.

## Flox output

The generator emits manifest schema version 1, deterministic `[install]`
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:

- `aarch64-darwin`
- `aarch64-linux`
- `x86_64-darwin`
- `x86_64-linux`

Unsupported platform identities fail before any file is written.

## Receipts and native locks

Manager files cannot safely carry Zed-only unknown fields, so provenance is
stored in a deterministic Zed-owned receipt. It records:

- raw input SHA-256;
- canonical normalized `EnvironmentPlan` SHA-256;
- output SHA-256;
- project-relative input and output paths;
- generator schema and manager;
- exact package mapping, platforms, and per-entry provenance digest; and
- `native_lock_required = true`.

The receipt does **not** pretend to be `devbox.lock` or Flox `manifest.lock`.
Manager-native lock generation, offline replay, and lock-digest attachment are a
separate validation step that must run with the actual manager binary.

## Conflict and write policy

Generation is byte-deterministic. Repeating an identical export succeeds
without rewriting either file. A differing existing output or receipt is
considered human-owned or drifted and is never overwritten.

When both paths are absent, each file is written through a same-directory
temporary file with no-clobber persistence. All conflicts are preflighted before
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.
94 changes: 94 additions & 0 deletions src/bin/zed-env-export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use std::path::{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};

#[derive(Debug, Parser)]
#[command(
name = "zed-env-export",
version,
about = "Generate deterministic Devbox and Flox environments from EnvironmentPlan"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
/// Emit a project-local `devbox.json` and Zed-owned provenance receipt.
Devbox(ExportArgs),
/// Emit `.flox/env/manifest.toml` and a Zed-owned provenance receipt.
Flox(ExportArgs),
}

#[derive(Debug, Args)]
struct ExportArgs {
/// Canonical EnvironmentPlan JSON input.
#[arg(
long,
default_value = ".zed/environment-plan.json",
env = "ZED_PKG_ENV_PLAN"
)]
plan: PathBuf,
/// Manager output path. Defaults to `devbox.json` or `.flox/env/manifest.toml`.
#[arg(long, env = "ZED_PKG_ENV_OUT")]
out: Option<PathBuf>,
/// Zed-owned deterministic export receipt path.
#[arg(long, env = "ZED_PKG_ENV_RECEIPT")]
receipt: Option<PathBuf>,
/// Emit a stable machine-readable result.
#[arg(long, env = "ZED_PKG_ENV_JSON")]
json: bool,
}

fn main() {
if let Err(error) = run(Cli::parse()) {
eprintln!("error: {error:#}");
std::process::exit(1);
}
}

fn run(cli: Cli) -> anyhow::Result<()> {
let 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,
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(())
}
Loading
Loading