From 748e92942523838bad499e093030a9b32890741e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:14:06 -0500 Subject: [PATCH 1/2] fix: replay frozen artifacts from the local store before registry access Use immutable lock metadata when an extracted store object or verified artifact cache is present, query the registry only for genuinely missing artifacts, and preserve the existing worker/process-lock/hash/extraction path. Add permanent three-platform certification covering cold download, registry deletion, extracted-store replay, and cache-only store reconstruction. The identical resolver implementation passed the merged two-OS compiled-CLI canary in zed-pkg-test/zed-pkg-e2e#57. --- .github/workflows/frozen-offline-prefetch.yml | 60 ++++++++++ src/install_graph/resolver.rs | 67 ++++++++--- tests/frozen_offline_prefetch.rs | 113 ++++++++++++++++++ 3 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/frozen-offline-prefetch.yml create mode 100644 tests/frozen_offline_prefetch.rs diff --git a/.github/workflows/frozen-offline-prefetch.yml b/.github/workflows/frozen-offline-prefetch.yml new file mode 100644 index 00000000..21963925 --- /dev/null +++ b/.github/workflows/frozen-offline-prefetch.yml @@ -0,0 +1,60 @@ +name: frozen offline prefetch + +on: + pull_request: + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + push: + branches: [main] + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frozen-offline-prefetch-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + frozen-offline-prefetch: + name: frozen offline prefetch / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + 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: Real pack, cold prefetch, registry deletion, and offline replay + run: cargo test --locked --test frozen_offline_prefetch -- --nocapture + + - name: Strict Clippy + run: cargo clippy --locked --test frozen_offline_prefetch -- -D warnings diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 4d97a762..452f2d8c 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -29,8 +29,7 @@ pub fn prefetch(project: &Path, cfg: &Config, frozen: bool) -> Result( } } -fn prefetch_locked( - project: &Path, - cfg: &Config, - registry: &dyn Registry, - concurrency: usize, -) -> Result { +fn locked_version_metadata(locked: &zed_interfaces::lockfile::LockedPackage) -> VersionMetadata { + VersionMetadata { + org: locked.org.clone(), + name: locked.name.clone(), + version: locked.version.clone(), + sha256: locked.sha256.clone(), + size: locked.size, + format: locked.format, + vcs_tag: locked.vcs_tag.clone(), + vcs_commit: locked.vcs_commit.clone(), + // The worker never consumes this URL while the authenticated bytes are + // already present in the store or verified artifact cache. + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } +} + +fn prefetch_locked(project: &Path, cfg: &Config, concurrency: usize) -> Result { let lock_path = project.join(LOCKFILE_FILE); let text = fs::read_to_string(&lock_path) .with_context(|| format!("--frozen requires {}", lock_path.display()))?; let lock = Lockfile::parse(&text)?; + let store = Store::new(&cfg.home); + let mut registry: Option> = None; let mut tasks = Vec::with_capacity(lock.packages.len()); let mut seen = BTreeSet::new(); @@ -99,17 +113,34 @@ fn prefetch_locked( if !seen.insert(key.clone()) { bail!("duplicate package `{key}` in {LOCKFILE_FILE}"); } - let version = registry.get_version(&locked.org, &locked.name, &locked.version)?; + + let version = + if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() { + // The lockfile authenticates every immutable field needed to + // verify locally owned bytes. Frozen replay must not turn a local + // restore into a registry metadata availability check. + locked_version_metadata(locked) + } else { + if registry.is_none() { + registry = Some(registry_for(&cfg.registry)?); + } + let version = registry + .as_deref() + .context("frozen prefetch registry was not initialized")? + .get_version(&locked.org, &locked.name, &locked.version)?; + validate_version_identity(&version, &locked.org, &locked.name, &locked.version)?; + if version.sha256 != locked.sha256 { + bail!( + "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", + key, + locked.version, + locked.sha256, + version.sha256 + ); + } + version + }; validate_version_identity(&version, &locked.org, &locked.name, &locked.version)?; - if version.sha256 != locked.sha256 { - bail!( - "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", - key, - locked.version, - locked.sha256, - version.sha256 - ); - } tasks.push(FetchTask { sequence: tasks.len(), key, diff --git a/tests/frozen_offline_prefetch.rs b/tests/frozen_offline_prefetch.rs new file mode 100644 index 00000000..83bc1c8f --- /dev/null +++ b/tests/frozen_offline_prefetch.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use zed_cli::config::Config; +use zed_cli::install_graph::prefetch; +use zed_cli::pack::pack; +use zed_cli::store::Store; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::manifest::Manifest; +use zed_interfaces::paths::{LOCKFILE_FILE, MANIFEST_FILE}; +use zed_interfaces::registry::VersionMetadata; + +fn test_config(registry: &Path, home: PathBuf) -> Config { + Config { + registry: format!("file://{}", registry.display()), + home, + token: None, + auth_url: "http://127.0.0.1/unused".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + } +} + +#[test] +fn frozen_prefetch_restores_from_store_and_cache_after_registry_metadata_disappears() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join("registry"); + let source = temp.path().join("source"); + let packed_dir = temp.path().join("packed"); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&project).unwrap(); + + let manifest_text = r#"[package] +org = "offline" +name = "tool" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/offline/tool" +"#; + fs::write(source.join(MANIFEST_FILE), manifest_text).unwrap(); + fs::write(source.join("payload.txt"), "offline/tool@1.0.0\n").unwrap(); + let manifest = Manifest::parse(manifest_text).unwrap(); + let packed = pack(&source, &manifest, Some(&packed_dir)).unwrap(); + + let artifact_dir = registry.join("artifacts"); + fs::create_dir_all(&artifact_dir).unwrap(); + let artifact = artifact_dir.join(format!("{}.tar.gz", packed.sha256)); + fs::copy(&packed.path, &artifact).unwrap(); + + let version_dir = registry + .join("packages") + .join("offline") + .join("tool") + .join("versions"); + fs::create_dir_all(&version_dir).unwrap(); + let version = VersionMetadata { + org: "offline".to_string(), + name: "tool".to_string(), + version: "1.0.0".to_string(), + sha256: packed.sha256.clone(), + size: packed.size, + format: packed.format, + vcs_tag: "v1.0.0".to_string(), + vcs_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + download_url: format!("file://{}", artifact.display()), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + }; + fs::write( + version_dir.join("1.0.0.json"), + serde_json::to_string_pretty(&version).unwrap(), + ) + .unwrap(); + + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: version.org, + name: version.name, + version: version.version, + sha256: version.sha256, + size: version.size, + format: version.format, + vcs_tag: version.vcs_tag, + vcs_commit: version.vcs_commit, + source: format!("file://{}", registry.display()), + }); + fs::write(project.join(LOCKFILE_FILE), lock.to_toml_string().unwrap()).unwrap(); + + let cfg = test_config(®istry, home); + let store = Store::new(&cfg.home); + let cold = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(cold.resolved, 1); + assert_eq!(cold.downloaded, 1); + assert!(store.has(&packed.sha256)); + assert!(store.cached_artifact(&packed.sha256).is_file()); + + fs::remove_dir_all(®istry).unwrap(); + let from_store = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_store.resolved, 1); + assert_eq!(from_store.downloaded, 0); + + fs::remove_dir_all(store.entry_dir(&packed.sha256)).unwrap(); + assert!(!store.has(&packed.sha256)); + let from_cache = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_cache.resolved, 1); + assert_eq!(from_cache.downloaded, 0); + assert!(store.has(&packed.sha256)); +} From 9789f40dadc56a18099e55c6d5c6076b7932c4c9 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:17:05 -0500 Subject: [PATCH 2/2] feat: add hardened global executable package profiles (#162) Add isolated global dependency profiles, symlink-first store materialization, owned executable publication, collision rollback, frozen offline restoration, compatibility routes, completion and documentation coverage, and permanent cross-platform certification. --- .github/workflows/global-packages.yml | 184 ++++ .zpkg.lock | 1 + .zpkg.toml | 45 + docs/global-packages.md | 114 +++ src/completion.rs | 28 +- src/global.rs | 1325 +++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 68 ++ src/ops.rs | 75 +- tests/global_cli.rs | 248 +++++ 10 files changed, 2074 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/global-packages.yml create mode 100644 .zpkg.lock create mode 100644 .zpkg.toml create mode 100644 docs/global-packages.md create mode 100644 src/global.rs create mode 100644 tests/global_cli.rs diff --git a/.github/workflows/global-packages.yml b/.github/workflows/global-packages.yml new file mode 100644 index 00000000..3ac2bb9e --- /dev/null +++ b/.github/workflows/global-packages.yml @@ -0,0 +1,184 @@ +name: Global executable package profiles + +on: + pull_request: + paths: + - .zpkg.toml + - .zpkg.lock + - docs/global-packages.md + - src/global.rs + - src/lib.rs + - src/main.rs + - src/completion.rs + - src/ops.rs + - tests/global_cli.rs + - .github/workflows/global-packages.yml + push: + branches: [main] + paths: + - .zpkg.toml + - .zpkg.lock + - docs/global-packages.md + - src/global.rs + - src/lib.rs + - src/main.rs + - src/completion.rs + - src/ops.rs + - tests/global_cli.rs + - .github/workflows/global-packages.yml + +permissions: + contents: read + +concurrency: + group: global-packages-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + certify: + name: Global profiles (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + 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 validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Check formatting + id: formatting + continue-on-error: true + run: | + set -euo pipefail + cargo fmt --all --check + + - name: Run global profile unit tests + id: unit + continue-on-error: true + run: | + set -euo pipefail + mkdir -p diagnostics + cargo test --quiet --locked --lib global::tests -- --nocapture \ + 2>&1 | tee "diagnostics/global-unit-tests-${RUNNER_OS}.log" + + - name: Upload focused unit-test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: global-unit-tests-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: diagnostics/ + if-no-files-found: error + retention-days: 3 + + - name: Enforce global profile unit tests + if: always() && steps.unit.outcome == 'failure' + run: | + echo 'focused global profile unit tests failed; see retained diagnostics.' >&2 + exit 1 + + - name: Run compiled global CLI tests + run: | + set -euo pipefail + cargo test --locked --test global_cli + + - name: Run strict Clippy + run: | + set -euo pipefail + cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Build and invoke the packaged command + run: | + set -euo pipefail + cargo build --release --locked --bin zed + if [[ "$RUNNER_OS" == "Windows" ]]; then + ./target/release/zed.exe --version + else + ./target/release/zed --version + fi + + - name: Run the complete repository suite on Linux + if: matrix.os == 'ubuntu-24.04' + run: | + set -euo pipefail + cargo test --all-targets --all-features --locked + + - name: Produce exact rustfmt evidence + if: always() && runner.os == 'Linux' && steps.formatting.outcome == 'failure' + run: | + set -euo pipefail + cargo fmt --all + python3 - <<'PY' + import pathlib + import shutil + import subprocess + + allowed = { + 'src/completion.rs', + 'src/global.rs', + 'src/lib.rs', + 'src/main.rs', + 'src/ops.rs', + 'tests/global_cli.rs', + } + changed = { + line.strip() + for line in subprocess.check_output( + ['git', 'diff', '--name-only'], text=True + ).splitlines() + if line.strip() + } + unexpected = changed - allowed + if unexpected: + raise SystemExit( + 'rustfmt changed files outside the reviewed feature boundary: ' + + ', '.join(sorted(unexpected)) + ) + if not changed: + raise SystemExit('format check failed but rustfmt produced no changed files') + root = pathlib.Path('format-evidence') + for name in sorted(changed): + destination = root / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(name, destination) + (root / 'changed-files.txt').write_text('\n'.join(sorted(changed)) + '\n') + PY + git diff --binary -- \ + src/completion.rs \ + src/global.rs \ + src/lib.rs \ + src/main.rs \ + src/ops.rs \ + tests/global_cli.rs \ + > format-evidence/rustfmt.patch + find format-evidence -type f -print0 | sort -z | xargs -0 sha256sum > format-evidence/SHA256SUMS + + - name: Upload rustfmt evidence + if: always() && runner.os == 'Linux' && steps.formatting.outcome == 'failure' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: global-package-rustfmt-${{ github.run_id }}-${{ github.run_attempt }} + path: format-evidence/ + if-no-files-found: error + retention-days: 3 + + - name: Enforce formatting result + if: always() && steps.formatting.outcome == 'failure' + run: | + echo 'cargo fmt --all --check failed; see the Linux rustfmt evidence artifact.' >&2 + exit 1 diff --git a/.zpkg.lock b/.zpkg.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/.zpkg.lock @@ -0,0 +1 @@ +version = 1 diff --git a/.zpkg.toml b/.zpkg.toml new file mode 100644 index 00000000..30e85c34 --- /dev/null +++ b/.zpkg.toml @@ -0,0 +1,45 @@ +[package] +org = "zed-pkg" +name = "zed-cli" +version = "0.1.0" +description = "The zed universal package-manager CLI" +license = "MIT" +keywords = ["package-manager", "cli", "polyglot", "vcs", "global-tools"] +language = "rust" + +[package.repository] +vcs = "git" +url = "https://github.com/zed-pkg/zed-cli" + +[build] +command = "cargo build --release --locked" +outputs = ["target/release/zed"] + +[bin] +zed = "target/release/zed" + +[publish] +include_readme = true +tag_format = "v{version}" +smoke_test = "test -x \"$ZED_PKG_TEST_TARGET/target/release/zed\" && \"$ZED_PKG_TEST_TARGET/target/release/zed\" --version" +exclude = [ + ".env", + ".env.*", + ".direnv/**", + ".zed/**", + ".zed-pack/**", + "target/**", + "tmp/**", + "**/*.log", + ".DS_Store", +] + +[publish.native] +registry = "crates-io" +package = "zed-cli" + +[install] +adapter = "none" + +[scripts] +test = "cargo test --all-targets --locked" diff --git a/docs/global-packages.md b/docs/global-packages.md new file mode 100644 index 00000000..2091649c --- /dev/null +++ b/docs/global-packages.md @@ -0,0 +1,114 @@ +# Global executable packages + +Zed keeps project dependencies project-local by default. A command-line tool is different: its executable should normally be available from the user's `PATH`, independent of any one repository. + +Use the explicit global package namespace: + +```sh +zed global install acme/tool +zed global install acme/tool@^2 +``` + +The npm/cargo-style spelling is an exact compatibility route to the same implementation: + +```sh +zed install --global acme/tool +``` + +## Storage model + +Each requested top-level package gets an isolated profile: + +```text +$ZED_PKG_HOME/global/profiles/// +├── .zpkg.lock +├── .zed-global-profile.json +└── zed_modules/ + ├── // + └── .bin/ +``` + +A profile resolves and locks its own complete dependency graph. Two global tools may therefore use incompatible versions of the same transitive package without forcing one global dependency solution. + +The package artifact still lives once in Zed's content-addressed store. A profile normally symlinks package trees from that store, while exposed executables are independent, executable copies owned by the global installer. + +## PATH directory + +On Unix-like systems, executables are copied to `~/.local/bin` by default. On Windows, Zed uses a per-user directory below the local application-data root. Print the exact path with: + +```sh +zed global bin-dir +``` + +Override it for one invocation or permanently: + +```sh +zed --global-bin-dir "$HOME/bin" global install acme/tool +export ZED_PKG_GLOBAL_BIN_DIR="$HOME/bin" +``` + +Zed reports when the selected directory is not on `PATH`; it never silently edits shell startup files or the Windows user environment. + +## Builds and executable declarations + +A package exposes commands through its root manifest: + +```toml +[build] +command = "cargo build --release --locked" +outputs = ["target/release/acme"] + +[bin] +acme = "target/release/acme" +``` + +Package build hooks execute author-supplied code and remain opt-in: + +```sh +zed global install acme/tool --allow-build +``` + +Prebuilt packages do not need a build hook. Their `[bin]` values may point directly at executable files already present in the artifact. + +## Ownership and collisions + +Zed records the package owner and SHA-256 hash of every executable it places in the global bin directory. + +- An unrelated existing command is never overwritten. +- Two installed packages exposing the same command name fail closed. +- Uninstall removes an executable only when its current bytes still match the version Zed installed. +- A command changed after installation is retained with a warning rather than deleted. + +These rules make a shared directory such as `~/.local/bin` safe to use alongside manually installed tools and other package managers. + +## Lifecycle + +```sh +# Inspect profiles and their commands +zed global list + +# Re-materialize every exact lock after moving machines or clearing the store +zed global install --frozen --allow-build + +# Re-materialize one exact profile +zed global install --frozen acme/tool --allow-build + +# Remove one profile and its still-owned commands +zed global uninstall acme/tool +zed uninstall --global acme/tool + +# Remove all Zed-managed global profiles +zed global uninstall +``` + +The global lock serializes profile and PATH mutations across terminals. Package downloads and builds continue to use the normal store and build-cache locks. + +## Installing Zed with Zed + +The `zed-pkg/zed-cli` repository is itself a Zed package. Once a bootstrap `zed` binary is available, it can install or upgrade the CLI through the same contract: + +```sh +zed global install zed-pkg/zed-cli --allow-build +``` + +The resulting `zed` executable is managed in the configured global bin directory; the package source and build output remain reproducibly locked in the isolated `zed-pkg/zed-cli` profile. diff --git a/src/completion.rs b/src/completion.rs index cc7c1247..34525dda 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -2,19 +2,31 @@ use std::io; +use anyhow::{Context, Result}; use clap_complete::{Shell, generate}; use crate::cli_model; -use crate::{dev, fetch, git_submodules, nix_bundle_write, nix_export_plan}; +use crate::{dev, fetch, git_submodules, global, nix_bundle_write, nix_export_plan}; -fn root_command() -> clap::Command { - git_submodules::augment_root_command(nix_bundle_write::augment_root_command( - nix_export_plan::augment_root_command(fetch::augment_root_command( - dev::augment_root_command(cli_model::command()), +/// Build the complete public command tree shared by root help and completion +/// generation. Every modular command must compose here rather than maintaining +/// a second, partial root-help model. +pub fn root_command() -> clap::Command { + global::augment_root_command(git_submodules::augment_root_command( + nix_bundle_write::augment_root_command(nix_export_plan::augment_root_command( + fetch::augment_root_command(dev::augment_root_command(cli_model::command())), )), )) } +/// Print the complete top-level help tree. +pub fn print_root_help() -> Result<()> { + let mut command = root_command(); + command.print_help().context("printing zed help")?; + println!(); + Ok(()) +} + /// Write a static completion script for `zed` to stdout. pub fn print(shell: Shell) { let mut command = root_command(); @@ -59,6 +71,8 @@ mod tests { "develop", "dev", "overtake", + "global", + "bin-dir", "completions", "self-update", "r2g", @@ -71,6 +85,7 @@ mod tests { "--skip-manifest", "--git-submodules", "--install-mode", + "--global-bin-dir", "--frozen", "--json", "--target", @@ -105,6 +120,8 @@ mod tests { "develop", "dev", "overtake", + "global", + "bin-dir", "completions", "self-update", "r2g", @@ -117,6 +134,7 @@ mod tests { "--skip-manifest", "--git-submodules", "--install-mode", + "--global-bin-dir", "--frozen", "--json", "--target", diff --git a/src/global.rs b/src/global.rs new file mode 100644 index 00000000..c70d7412 --- /dev/null +++ b/src/global.rs @@ -0,0 +1,1325 @@ +//! Global executable package profiles. +//! +//! Project installs remain the default: dependencies belong beside the project +//! that declared them. Global installs are an explicit tool-distribution path. +//! Each requested top-level package gets an isolated manifestless profile under +//! `/global/profiles//`, while its hoisted executables +//! are copied transactionally into one user bin directory (`~/.local/bin` by +//! default). Isolated profiles let two tools retain different transitive +//! dependency versions without turning the global environment into one giant +//! resolver. + +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use clap::{Args, Parser, Subcommand}; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zed_interfaces::lockfile::Lockfile; +use zed_interfaces::manifest::is_slug; +use zed_interfaces::paths::{BIN_DIR, LOCKFILE_FILE, MODULES_DIR}; + +use crate::cli::{Adapter, Globals, InstallMode}; +use crate::config::Config; +use crate::{interactive, manifestless}; + +const GLOBAL_DIR: &str = "global"; +const PROFILES_DIR: &str = "profiles"; +const PROFILE_FILE: &str = ".zed-global-profile.json"; +const STATE_FILE: &str = "managed-bins.json"; +const LOCK_FILE: &str = ".lock"; + +#[derive(Debug, Args)] +struct GlobalArgs { + #[command(subcommand)] + command: GlobalAction, +} + +#[derive(Debug, Subcommand)] +enum GlobalAction { + /// Install one or more executable packages into isolated global profiles. + Install(GlobalInstallArgs), + /// Remove global package profiles and their managed PATH entries. + #[command(alias = "un")] + Uninstall(GlobalUninstallArgs), + /// List globally installed top-level packages and exposed binaries. + List, + /// Print the directory where global executable copies are managed. + #[command(name = "bin-dir")] + BinDir, +} + +#[derive(Debug, Args)] +struct GlobalInstallArgs { + /// Package specs (`org/name[@requirement]`). With --frozen, these are + /// optional installed-profile selectors; omit them to restore every profile. + #[arg(value_name = "PACKAGE")] + specs: Vec, + + /// Reinstall exactly what each selected global profile lockfile pins. + #[arg(long, env = "ZED_PKG_FROZEN")] + frozen: bool, + + /// Materialize profile package trees by symlink or copy. Executables placed + /// on PATH are always independently owned copies. + #[arg( + long, + value_enum, + env = "ZED_PKG_INSTALL_MODE", + default_value = "symlink" + )] + install_mode: InstallMode, + + /// Run package-declared build hooks. Source-distributed CLIs generally need + /// this once so their declared [bin] output exists. + #[arg(long, env = "ZED_PKG_ALLOW_BUILD")] + allow_build: bool, + + /// Select a specific polyglot target before exposing its binaries. + #[arg(long, env = "ZED_PKG_TARGET")] + target: Option, +} + +#[derive(Debug, Args)] +struct GlobalUninstallArgs { + /// Installed top-level package identities (`org/name`). Omit to remove all. + #[arg(value_name = "PACKAGE")] + specs: Vec, +} + +#[derive(Debug, Parser)] +#[command( + name = "zed", + version, + about = "zed: the universal package manager backed by the VCS hosts you already use" +)] +struct GlobalCli { + #[command(flatten)] + globals: Globals, + + /// Directory placed on PATH for globally installed package executables. + #[arg( + long, + global = true, + env = "ZED_PKG_GLOBAL_BIN_DIR", + value_name = "PATH" + )] + global_bin_dir: Option, + + #[command(subcommand)] + command: GlobalCommand, +} + +#[derive(Debug, Subcommand)] +enum GlobalCommand { + /// Manage executable packages outside any one project. `zed install + /// --global ...` and `zed uninstall --global ...` route here too. + Global(GlobalArgs), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Route { + Global, + Alias { + command_index: usize, + global_index: usize, + action: &'static str, + }, + GlobalHelp { + help_index: usize, + target_index: usize, + }, + Existing, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProfileMetadata { + package: String, + requested: String, +} + +#[derive(Debug, Clone)] +struct Profile { + root: PathBuf, + metadata: ProfileMetadata, +} + +#[derive(Debug, Clone)] +struct DesiredBin { + package: String, + source: PathBuf, + sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ManagedBin { + package: String, + sha256: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ManagedState { + #[serde(default)] + bins: BTreeMap, +} + +#[derive(Debug)] +struct StagedProfile { + root: PathBuf, + backup: Option, +} + +#[derive(Debug)] +struct BinBackup { + destination: PathBuf, + backup: PathBuf, +} + +#[derive(Debug)] +struct StagedExecutable { + destination_name: String, + destination: PathBuf, + temporary: PathBuf, + desired: DesiredBin, +} + +struct GlobalLock(fs::File); + +impl Drop for GlobalLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } +} + +/// Route the modular global-package command before the typed project command +/// parser. The canonical form is `zed global install`; the npm/cargo-style +/// `zed install --global` spelling is rewritten to the same parser and codepath. +pub fn dispatch(args: Vec) -> Option> { + match route(&args) { + Route::Global => Some(run_cli(args)), + Route::Alias { + command_index, + global_index, + action, + } => { + let mut rewritten = args; + rewritten.remove(global_index); + let command_index = if global_index < command_index { + command_index - 1 + } else { + command_index + }; + rewritten[command_index] = OsString::from("global"); + rewritten.insert(command_index + 1, OsString::from(action)); + Some(run_cli(rewritten)) + } + Route::GlobalHelp { + help_index, + target_index, + } => { + let mut rewritten = args; + rewritten[help_index] = OsString::from("global"); + rewritten.remove(target_index); + rewritten.push(OsString::from("--help")); + Some(run_cli(rewritten)) + } + Route::Existing => None, + } +} + +/// Add `zed global` to root help and generated completions without forcing the +/// established project command enum to own a second installation lifecycle. +pub fn augment_root_command(command: clap::Command) -> clap::Command { + if command + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "global") + { + return command; + } + let global = ::augment_args( + clap::Command::new("global") + .about("Install executable packages globally and manage their PATH entries"), + ) + .arg( + clap::Arg::new("global-bin-dir") + .long("global-bin-dir") + .global(true) + .env("ZED_PKG_GLOBAL_BIN_DIR") + .value_name("PATH") + .help("Directory placed on PATH for global package executables"), + ); + command.subcommand(global) +} + +fn run_cli(args: Vec) -> Result { + let cli = match GlobalCli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) => { + let code = error.exit_code(); + error + .print() + .context("printing zed global argument error")?; + return Ok(code); + } + }; + let cfg = Config::from_globals(&cli.globals)?; + let bin_dir = resolve_bin_dir(&cfg, cli.global_bin_dir.as_deref()); + match cli.command { + GlobalCommand::Global(args) => match args.command { + GlobalAction::Install(options) => install(&cfg, &bin_dir, options), + GlobalAction::Uninstall(options) => uninstall(&cfg, &bin_dir, options), + GlobalAction::List => list(&cfg, &bin_dir), + GlobalAction::BinDir => { + println!("{}", bin_dir.display()); + Ok(0) + } + }, + } +} + +fn route(args: &[OsString]) -> Route { + let Some((command_index, command)) = first_command(args) else { + return Route::Existing; + }; + + match command.as_str() { + "global" => Route::Global, + "install" | "i" => global_flag(args).map_or(Route::Existing, |global_index| Route::Alias { + command_index, + global_index, + action: "install", + }), + "uninstall" | "un" => { + global_flag(args).map_or(Route::Existing, |global_index| Route::Alias { + command_index, + global_index, + action: "uninstall", + }) + } + "help" => match next_positional(args, command_index + 1) { + Some((target_index, target)) if target == "global" => Route::GlobalHelp { + help_index: command_index, + target_index, + }, + _ => Route::Existing, + }, + _ => Route::Existing, + } +} + +fn first_command(args: &[OsString]) -> Option<(usize, String)> { + let mut index = 1; + while index < args.len() { + let token = args[index].to_string_lossy(); + if token == "--" { + return next_positional(args, index + 1); + } + if global_option_takes_value(&token) { + index += if token.contains('=') { 1 } else { 2 }; + continue; + } + if token.starts_with('-') { + index += 1; + continue; + } + return Some((index, token.into_owned())); + } + None +} + +fn next_positional(args: &[OsString], mut index: usize) -> Option<(usize, String)> { + while index < args.len() { + let token = args[index].to_string_lossy(); + if !token.starts_with('-') { + return Some((index, token.into_owned())); + } + index += 1; + } + None +} + +fn global_option_takes_value(token: &str) -> bool { + const OPTIONS: &[&str] = &[ + "--registry", + "--home", + "--token", + "--auth-url", + "--supabase-url", + "--supabase-key", + "--global-bin-dir", + ]; + OPTIONS.iter().any(|option| { + token == *option + || token + .strip_prefix(option) + .is_some_and(|remainder| remainder.starts_with('=')) + }) +} + +fn global_flag(args: &[OsString]) -> Option { + args.iter() + .position(|value| value == OsStr::new("--global")) +} + +fn acquire_lock(cfg: &Config) -> Result { + let root = global_root(cfg); + fs::create_dir_all(&root)?; + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(root.join(LOCK_FILE))?; + file.lock_exclusive() + .context("locking global package profiles")?; + Ok(GlobalLock(file)) +} + +fn global_root(cfg: &Config) -> PathBuf { + cfg.home.join(GLOBAL_DIR) +} + +fn profiles_root(cfg: &Config) -> PathBuf { + global_root(cfg).join(PROFILES_DIR) +} + +fn state_path(cfg: &Config) -> PathBuf { + global_root(cfg).join(STATE_FILE) +} + +#[cfg(windows)] +fn resolve_bin_dir(cfg: &Config, explicit: Option<&Path>) -> PathBuf { + explicit + .map(Path::to_path_buf) + .or_else(|| dirs::data_local_dir().map(|root| root.join("zed-pkg").join("bin"))) + .unwrap_or_else(|| cfg.home.join("bin")) +} + +#[cfg(not(windows))] +fn resolve_bin_dir(cfg: &Config, explicit: Option<&Path>) -> PathBuf { + explicit + .map(Path::to_path_buf) + .or_else(|| dirs::home_dir().map(|home| home.join(".local").join("bin"))) + .unwrap_or_else(|| cfg.home.join("bin")) +} + +fn parse_package_spec(spec: &str) -> Result<(String, Option)> { + let spec = spec.trim(); + if spec.is_empty() { + bail!("package spec cannot be empty"); + } + let (key, requirement) = match spec.rsplit_once('@') { + Some((key, requirement)) if key.contains('/') => { + if requirement.trim().is_empty() { + bail!("empty requirement in package spec `{spec}`"); + } + (key, Some(requirement.trim().to_string())) + } + _ => (spec, None), + }; + let (org, name) = key + .split_once('/') + .filter(|(org, name)| !org.is_empty() && !name.is_empty()) + .with_context(|| { + format!("invalid package spec `{spec}` (expected org/name[@requirement])") + })?; + if name.contains('/') || !is_slug(org) || !is_slug(name) { + bail!("invalid package identity `{key}`; expected lowercase slug org/name"); + } + Ok((format!("{org}/{name}"), requirement)) +} + +fn parse_install_specs(specs: &[String]) -> Result> { + let mut seen = BTreeSet::new(); + let mut parsed = Vec::with_capacity(specs.len()); + for requested in specs { + let (key, _) = parse_package_spec(requested)?; + if !seen.insert(key.clone()) { + bail!("global package `{key}` was requested more than once"); + } + parsed.push((requested.clone(), key)); + } + Ok(parsed) +} + +fn profile_root(cfg: &Config, key: &str) -> Result { + let (org, name) = key + .split_once('/') + .context("validated package identity contains a slash")?; + if !is_slug(org) || !is_slug(name) { + bail!("invalid global package identity `{key}`"); + } + Ok(profiles_root(cfg).join(org).join(name)) +} + +fn install(cfg: &Config, bin_dir: &Path, options: GlobalInstallArgs) -> Result { + let _lock = acquire_lock(cfg)?; + if options.frozen { + let profiles = selected_profiles(cfg, &options.specs)?; + if profiles.is_empty() { + bail!("no global package profiles are installed"); + } + for profile in &profiles { + manifestless::install( + &profile.root, + cfg, + &[], + true, + options.install_mode, + Adapter::None, + options.allow_build, + options.target.as_deref(), + true, + true, + )?; + } + let profiles = discover_profiles(cfg)?; + let installed = sync_bins(cfg, bin_dir, &profiles)?; + print_path_guidance(bin_dir); + println!( + "{} global package profile(s); {} executable(s) managed in {}", + profiles.len(), + installed, + bin_dir.display() + ); + return Ok(0); + } + + if options.specs.is_empty() { + bail!( + "global install needs one or more `org/name[@requirement]` package specs; use --frozen to restore existing profiles" + ); + } + + let requested = parse_install_specs(&options.specs)?; + let mut staged_profiles = Vec::with_capacity(requested.len()); + let result = (|| -> Result<(usize, usize)> { + for (spec, key) in &requested { + let root = profile_root(cfg, key)?; + staged_profiles.push(stage_profile_replacement(&root)?); + manifestless::install( + &root, + cfg, + std::slice::from_ref(spec), + false, + options.install_mode, + Adapter::None, + options.allow_build, + options.target.as_deref(), + true, + true, + )?; + write_metadata( + &root, + &ProfileMetadata { + package: key.clone(), + requested: spec.clone(), + }, + )?; + if profile_bins(&root)?.is_empty() { + eprintln!( + "warning: {key} currently exposes no built [bin] entries in this profile; if it declares a [build] step, reinstall with --allow-build" + ); + } + } + let profiles = discover_profiles(cfg)?; + let installed = sync_bins(cfg, bin_dir, &profiles)?; + Ok((profiles.len(), installed)) + })(); + + match result { + Ok((profile_count, installed)) => { + discard_profile_backups(&staged_profiles); + print_path_guidance(bin_dir); + println!( + "{profile_count} global package profile(s); {installed} executable(s) managed in {}", + bin_dir.display() + ); + Ok(0) + } + Err(error) => { + if let Err(rollback) = rollback_staged_profiles(&staged_profiles) { + return Err(anyhow!( + "global installation failed: {error:#}; profile rollback also failed: {rollback:#}" + )); + } + Err(error) + } + } +} + +fn uninstall(cfg: &Config, bin_dir: &Path, options: GlobalUninstallArgs) -> Result { + let _lock = acquire_lock(cfg)?; + let profiles = selected_profiles(cfg, &options.specs)?; + if profiles.is_empty() { + println!("no matching global package profiles"); + return Ok(0); + } + for profile in &profiles { + interactive::confirm( + cfg.interactive, + &format!("remove global package profile {}", profile.metadata.package), + )?; + } + + let mut staged_profiles = Vec::with_capacity(profiles.len()); + let result = (|| -> Result<(usize, usize)> { + for profile in &profiles { + staged_profiles.push(stage_profile_removal(&profile.root)?); + } + let remaining = discover_profiles(cfg)?; + let installed = sync_bins(cfg, bin_dir, &remaining)?; + Ok((remaining.len(), installed)) + })(); + + match result { + Ok((remaining, installed)) => { + discard_profile_backups(&staged_profiles); + for profile in &profiles { + println!("uninstalled {}", profile.metadata.package); + } + println!( + "{remaining} global package profile(s) remain; {installed} executable(s) managed in {}", + bin_dir.display() + ); + Ok(0) + } + Err(error) => { + if let Err(rollback) = rollback_staged_profiles(&staged_profiles) { + return Err(anyhow!( + "global uninstall failed: {error:#}; profile rollback also failed: {rollback:#}" + )); + } + Err(error) + } + } +} + +fn list(cfg: &Config, bin_dir: &Path) -> Result { + let profiles = discover_profiles(cfg)?; + if profiles.is_empty() { + println!("no global package profiles installed"); + } + for profile in profiles { + let version = locked_root_version(&profile).unwrap_or_else(|| "unknown".to_string()); + let mut bins: Vec = profile_bins(&profile.root)?.into_keys().collect(); + bins.sort(); + println!( + "{}@{} (requested `{}`; bins: {})", + profile.metadata.package, + version, + profile.metadata.requested, + if bins.is_empty() { + "none".to_string() + } else { + bins.join(", ") + } + ); + } + println!("global bin directory: {}", bin_dir.display()); + Ok(0) +} + +fn selected_profiles(cfg: &Config, specs: &[String]) -> Result> { + let profiles = discover_profiles(cfg)?; + if specs.is_empty() { + return Ok(profiles); + } + let mut by_key: BTreeMap = profiles + .into_iter() + .map(|profile| (profile.metadata.package.clone(), profile)) + .collect(); + let mut selected = Vec::new(); + let mut seen = BTreeSet::new(); + for spec in specs { + let (key, requirement) = parse_package_spec(spec)?; + if requirement.is_some() { + bail!( + "global profile selectors must be package identities without versions (got `{spec}`)" + ); + } + if !seen.insert(key.clone()) { + bail!("global package `{key}` was selected more than once"); + } + let profile = by_key + .remove(&key) + .with_context(|| format!("global package `{key}` is not installed"))?; + selected.push(profile); + } + Ok(selected) +} + +fn discover_profiles(cfg: &Config) -> Result> { + let root = profiles_root(cfg); + if !root.is_dir() { + return Ok(Vec::new()); + } + let mut profiles = Vec::new(); + for org in fs::read_dir(&root)? { + let org = org?; + if !org.file_type()?.is_dir() { + continue; + } + let org_name = org.file_name().to_string_lossy().to_string(); + if !is_slug(&org_name) { + continue; + } + for package in fs::read_dir(org.path())? { + let package = package?; + if !package.file_type()?.is_dir() { + continue; + } + let package_name = package.file_name().to_string_lossy().to_string(); + if !is_slug(&package_name) { + continue; + } + let metadata_path = package.path().join(PROFILE_FILE); + if !metadata_path.is_file() { + continue; + } + let metadata: ProfileMetadata = serde_json::from_slice(&fs::read(&metadata_path)?) + .with_context(|| format!("parsing {}", metadata_path.display()))?; + let expected = format!("{org_name}/{package_name}"); + if metadata.package != expected { + bail!( + "{} claims package `{}` but its managed path requires `{expected}`", + metadata_path.display(), + metadata.package + ); + } + profiles.push(Profile { + root: package.path(), + metadata, + }); + } + } + profiles.sort_by(|left, right| left.metadata.package.cmp(&right.metadata.package)); + Ok(profiles) +} + +fn stage_profile_replacement(root: &Path) -> Result { + let backup = if path_present(root) { + let backup = unique_sibling(root, "profile-backup")?; + fs::rename(root, &backup).with_context(|| { + format!( + "staging existing global profile {} at {}", + root.display(), + backup.display() + ) + })?; + Some(backup) + } else { + None + }; + if let Err(error) = fs::create_dir_all(root) { + if let Some(backup) = &backup { + let _ = fs::rename(backup, root); + } + return Err(error).with_context(|| format!("creating global profile {}", root.display())); + } + Ok(StagedProfile { + root: root.to_path_buf(), + backup, + }) +} + +fn stage_profile_removal(root: &Path) -> Result { + if !path_present(root) { + bail!( + "global profile disappeared before removal: {}", + root.display() + ); + } + let backup = unique_sibling(root, "profile-removal")?; + fs::rename(root, &backup).with_context(|| { + format!( + "staging global profile removal {} at {}", + root.display(), + backup.display() + ) + })?; + Ok(StagedProfile { + root: root.to_path_buf(), + backup: Some(backup), + }) +} + +fn rollback_staged_profiles(staged: &[StagedProfile]) -> Result<()> { + let mut failures = Vec::new(); + for profile in staged.iter().rev() { + if let Err(error) = remove_path_if_present(&profile.root) { + failures.push(format!("remove {}: {error:#}", profile.root.display())); + continue; + } + if let Some(backup) = &profile.backup + && path_present(backup) + && let Err(error) = fs::rename(backup, &profile.root) + { + failures.push(format!( + "restore {} from {}: {error}", + profile.root.display(), + backup.display() + )); + } + } + if failures.is_empty() { + Ok(()) + } else { + bail!("{}", failures.join("; ")) + } +} + +fn discard_profile_backups(staged: &[StagedProfile]) { + for profile in staged { + if let Some(backup) = &profile.backup + && let Err(error) = remove_path_if_present(backup) + { + eprintln!( + "warning: could not remove committed profile backup {}: {error:#}", + backup.display() + ); + } + } +} + +fn write_metadata(root: &Path, metadata: &ProfileMetadata) -> Result<()> { + fs::create_dir_all(root)?; + atomic_write( + &root.join(PROFILE_FILE), + &serde_json::to_vec_pretty(metadata)?, + ) +} + +fn locked_root_version(profile: &Profile) -> Option { + let text = fs::read_to_string(profile.root.join(LOCKFILE_FILE)).ok()?; + let lock = Lockfile::parse(&text).ok()?; + let (org, name) = profile.metadata.package.split_once('/')?; + lock.find(org, name).map(|package| package.version.clone()) +} + +fn profile_bins(root: &Path) -> Result> { + let bin_dir = root.join(MODULES_DIR).join(BIN_DIR); + if !bin_dir.is_dir() { + return Ok(BTreeMap::new()); + } + let mut bins = BTreeMap::new(); + for entry in fs::read_dir(&bin_dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow!("global bin name is not valid UTF-8"))?; + validate_bin_name(&name)?; + bins.insert(name, entry.path()); + } + Ok(bins) +} + +fn validate_bin_name(name: &str) -> Result<()> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+')) + { + bail!("unsafe global bin name `{name}`"); + } + Ok(()) +} + +fn collect_desired_bins(profiles: &[Profile]) -> Result> { + let mut desired: BTreeMap = BTreeMap::new(); + for profile in profiles { + for (logical_name, source) in profile_bins(&profile.root)? { + let destination = destination_name(&logical_name); + if let Some(existing) = desired.get(&destination) { + bail!( + "global bin collision for `{destination}` between {} and {}; uninstall one package or use separate ZED_PKG_GLOBAL_BIN_DIR values", + existing.package, + profile.metadata.package + ); + } + desired.insert( + destination, + DesiredBin { + package: profile.metadata.package.clone(), + sha256: hash_file(&source)?, + source, + }, + ); + } + } + Ok(desired) +} + +#[cfg(windows)] +fn destination_name(logical_name: &str) -> String { + if logical_name.to_ascii_lowercase().ends_with(".exe") { + logical_name.to_string() + } else { + format!("{logical_name}.exe") + } +} + +#[cfg(not(windows))] +fn destination_name(logical_name: &str) -> String { + logical_name.to_string() +} + +fn load_state(cfg: &Config) -> Result { + let path = state_path(cfg); + if !path.is_file() { + return Ok(ManagedState::default()); + } + serde_json::from_slice(&fs::read(&path)?) + .with_context(|| format!("parsing managed global-bin state at {}", path.display())) +} + +fn sync_bins(cfg: &Config, bin_dir: &Path, profiles: &[Profile]) -> Result { + let desired = collect_desired_bins(profiles)?; + let previous = load_state(cfg)?; + + let mut stale_owned = Vec::new(); + for (name, managed) in &previous.bins { + if desired.contains_key(name) { + continue; + } + let destination = bin_dir.join(name); + if !path_present(&destination) { + continue; + } + let current = hash_file(&destination)?; + if current == managed.sha256 { + stale_owned.push(destination); + } else { + eprintln!( + "warning: leaving {} because it changed after Zed installed it", + destination.display() + ); + } + } + + for (name, wanted) in &desired { + let destination = bin_dir.join(name); + if !path_present(&destination) { + continue; + } + let current = hash_file(&destination)?; + let owned = previous + .bins + .get(name) + .is_some_and(|managed| managed.sha256 == current); + if !owned { + bail!( + "refusing to replace unmanaged global executable {}; choose another --global-bin-dir or remove the collision explicitly", + destination.display() + ); + } + if wanted.sha256.is_empty() { + bail!("empty desired hash for global executable `{name}`"); + } + } + + fs::create_dir_all(bin_dir)?; + let mut staged = Vec::with_capacity(desired.len()); + for (name, wanted) in desired { + let destination = bin_dir.join(&name); + let temporary = stage_executable(&wanted.source, &destination)?; + staged.push(StagedExecutable { + destination_name: name, + destination, + temporary, + desired: wanted, + }); + } + + let mut backups = Vec::new(); + let mut promoted = Vec::new(); + let transaction = (|| -> Result { + let mut destinations = stale_owned; + destinations.extend( + staged + .iter() + .filter(|entry| path_present(&entry.destination)) + .map(|entry| entry.destination.clone()), + ); + destinations.sort(); + destinations.dedup(); + for destination in destinations { + let backup = unique_sibling(&destination, "bin-backup")?; + fs::rename(&destination, &backup).with_context(|| { + format!( + "staging managed executable {} at {}", + destination.display(), + backup.display() + ) + })?; + backups.push(BinBackup { + destination, + backup, + }); + } + + let mut next = ManagedState::default(); + for entry in &staged { + fs::rename(&entry.temporary, &entry.destination).with_context(|| { + format!( + "promoting global executable into {}", + entry.destination.display() + ) + })?; + promoted.push(entry.destination.clone()); + next.bins.insert( + entry.destination_name.clone(), + ManagedBin { + package: entry.desired.package.clone(), + sha256: entry.desired.sha256.clone(), + }, + ); + } + atomic_write(&state_path(cfg), &serde_json::to_vec_pretty(&next)?)?; + Ok(next) + })(); + + match transaction { + Ok(next) => { + for backup in &backups { + if let Err(error) = remove_path_if_present(&backup.backup) { + eprintln!( + "warning: could not remove committed executable backup {}: {error:#}", + backup.backup.display() + ); + } + } + for entry in &staged { + let _ = remove_path_if_present(&entry.temporary); + } + Ok(next.bins.len()) + } + Err(error) => { + rollback_bin_transaction(&staged, &promoted, &backups)?; + Err(error) + } + } +} + +fn stage_executable(source: &Path, destination: &Path) -> Result { + let parent = destination + .parent() + .context("global executable destination has no parent")?; + fs::create_dir_all(parent)?; + let file_name = destination + .file_name() + .and_then(|name| name.to_str()) + .context("global executable name is not valid UTF-8")?; + let temporary = parent.join(format!(".{file_name}.zed-stage-{}", uuid::Uuid::new_v4())); + fs::copy(source, &temporary).with_context(|| { + format!( + "staging global executable {} at {}", + source.display(), + temporary.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&temporary)?.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(&temporary, permissions)?; + } + Ok(temporary) +} + +fn rollback_bin_transaction( + staged: &[StagedExecutable], + promoted: &[PathBuf], + backups: &[BinBackup], +) -> Result<()> { + let mut failures = Vec::new(); + for destination in promoted.iter().rev() { + if let Err(error) = remove_path_if_present(destination) { + failures.push(format!("remove {}: {error:#}", destination.display())); + } + } + for backup in backups.iter().rev() { + if path_present(&backup.backup) + && let Err(error) = fs::rename(&backup.backup, &backup.destination) + { + failures.push(format!( + "restore {} from {}: {error}", + backup.destination.display(), + backup.backup.display() + )); + } + } + for entry in staged { + if let Err(error) = remove_path_if_present(&entry.temporary) { + failures.push(format!("remove {}: {error:#}", entry.temporary.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + bail!( + "rolling back global executable transaction: {}", + failures.join("; ") + ) + } +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("managed file has no parent")?; + fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("managed file name is not valid UTF-8")?; + let temporary = parent.join(format!(".{file_name}.zed-tmp-{}", uuid::Uuid::new_v4())); + fs::write(&temporary, bytes)?; + + let backup = if path_present(path) { + let backup = unique_sibling(path, "write-backup")?; + if let Err(error) = fs::rename(path, &backup) { + let _ = remove_path_if_present(&temporary); + return Err(error).with_context(|| format!("staging {}", path.display())); + } + Some(backup) + } else { + None + }; + + if let Err(error) = fs::rename(&temporary, path) { + let _ = remove_path_if_present(&temporary); + if let Some(backup) = &backup { + let _ = fs::rename(backup, path); + } + return Err(error).with_context(|| format!("promoting {}", path.display())); + } + if let Some(backup) = backup + && let Err(error) = remove_path_if_present(&backup) + { + eprintln!( + "warning: could not remove committed write backup {}: {error:#}", + backup.display() + ); + } + Ok(()) +} + +fn unique_sibling(path: &Path, kind: &str) -> Result { + let parent = path.parent().context("managed path has no parent")?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .context("managed path name is not valid UTF-8")?; + Ok(parent.join(format!(".{name}.zed-{kind}-{}", uuid::Uuid::new_v4()))) +} + +fn path_present(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn remove_path_if_present(path: &Path) -> Result<()> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + Ok(()) +} + +fn hash_file(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +#[cfg(windows)] +fn print_path_guidance(bin_dir: &Path) { + if path_contains(bin_dir) { + return; + } + eprintln!( + "warning: {} is not currently on PATH; add it to the user PATH once", + bin_dir.display() + ); +} + +#[cfg(not(windows))] +fn print_path_guidance(bin_dir: &Path) { + if path_contains(bin_dir) { + return; + } + eprintln!( + "warning: {} is not currently on PATH; add it once, for example:\n export PATH=\"{}:$PATH\"", + bin_dir.display(), + bin_dir.display() + ); +} + +fn path_contains(bin_dir: &Path) -> bool { + let expected = fs::canonicalize(bin_dir).unwrap_or_else(|_| bin_dir.to_path_buf()); + env::var_os("PATH").is_some_and(|value| { + env::split_paths(&value).any(|entry| fs::canonicalize(&entry).unwrap_or(entry) == expected) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(home: &Path) -> Config { + Config { + registry: "file:///unused".to_string(), + home: home.to_path_buf(), + token: None, + auth_url: "https://localhost/shared-auth".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + } + } + + fn add_profile(home: &Path, key: &str, bin_name: &str, contents: &[u8]) -> Profile { + let cfg = config(home); + let root = profile_root(&cfg, key).unwrap(); + let bin = root.join(MODULES_DIR).join(BIN_DIR).join(bin_name); + fs::create_dir_all(bin.parent().unwrap()).unwrap(); + fs::write(&bin, contents).unwrap(); + let metadata = ProfileMetadata { + package: key.to_string(), + requested: key.to_string(), + }; + write_metadata(&root, &metadata).unwrap(); + Profile { root, metadata } + } + + #[test] + fn package_specs_are_strict_and_traversal_safe() { + assert_eq!( + parse_package_spec("acme/tool@^1").unwrap(), + ("acme/tool".to_string(), Some("^1".to_string())) + ); + assert_eq!( + parse_package_spec("acme/tool").unwrap(), + ("acme/tool".to_string(), None) + ); + for invalid in [ + "tool", + "acme/tool/extra", + "../tool", + "acme/../tool", + "Acme/tool", + ] { + assert!(parse_package_spec(invalid).is_err(), "accepted {invalid}"); + } + } + + #[test] + fn duplicate_install_specs_fail_before_profile_mutation() { + let error = parse_install_specs(&["acme/tool@1".to_string(), "acme/tool@2".to_string()]) + .unwrap_err(); + assert!(error.to_string().contains("requested more than once")); + } + + #[test] + fn alias_route_rewrites_install_global() { + let args = vec![ + OsString::from("zed"), + OsString::from("install"), + OsString::from("--global"), + OsString::from("acme/tool"), + ]; + assert!(matches!( + route(&args), + Route::Alias { + action: "install", + .. + } + )); + } + + #[test] + fn global_bins_are_copied_and_tampered_files_are_preserved() { + let home = tempfile::tempdir().unwrap(); + let bin_dir = home.path().join("path-bin"); + let cfg = config(home.path()); + let profile = add_profile(home.path(), "acme/tool", "acme-tool", b"first"); + + assert_eq!( + sync_bins(&cfg, &bin_dir, std::slice::from_ref(&profile)).unwrap(), + 1 + ); + let installed = bin_dir.join(destination_name("acme-tool")); + assert_eq!(fs::read(&installed).unwrap(), b"first"); + + fs::write(&installed, b"user replacement").unwrap(); + assert_eq!(sync_bins(&cfg, &bin_dir, &[]).unwrap(), 0); + assert_eq!(fs::read(&installed).unwrap(), b"user replacement"); + } + + #[test] + fn bin_collisions_fail_before_path_mutation() { + let home = tempfile::tempdir().unwrap(); + let first = add_profile(home.path(), "acme/one", "tool", b"one"); + let second = add_profile(home.path(), "acme/two", "tool", b"two"); + let error = collect_desired_bins(&[first, second]).unwrap_err(); + assert!(error.to_string().contains("collision")); + } + + #[test] + fn unmanaged_collision_preflight_does_not_install_other_commands() { + let home = tempfile::tempdir().unwrap(); + let cfg = config(home.path()); + let bin_dir = home.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join(destination_name("tool")), b"unmanaged").unwrap(); + let first = add_profile(home.path(), "acme/one", "alpha", b"alpha"); + let second = add_profile(home.path(), "acme/two", "tool", b"managed candidate"); + + let error = sync_bins(&cfg, &bin_dir, &[first, second]).unwrap_err(); + assert!(error.to_string().contains("unmanaged global executable")); + assert!(!bin_dir.join(destination_name("alpha")).exists()); + assert_eq!( + fs::read(bin_dir.join(destination_name("tool"))).unwrap(), + b"unmanaged" + ); + } + + #[test] + fn profile_replacement_rolls_back_exact_previous_tree() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join("profiles").join("acme").join("tool"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("previous"), b"preserve me").unwrap(); + let staged = stage_profile_replacement(&root).unwrap(); + fs::write(root.join("new"), b"discard me").unwrap(); + + rollback_staged_profiles(&[staged]).unwrap(); + assert_eq!(fs::read(root.join("previous")).unwrap(), b"preserve me"); + assert!(!root.join("new").exists()); + } + + #[test] + fn explicit_global_bin_directory_wins() { + let home = tempfile::tempdir().unwrap(); + let cfg = config(home.path()); + let explicit = home.path().join("custom-bin"); + assert_eq!(resolve_bin_dir(&cfg, Some(&explicit)), explicit); + } +} diff --git a/src/lib.rs b/src/lib.rs index 811383da..8e822f71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod environment; pub mod fetch; pub mod flags; pub mod git_submodules; +pub mod global; pub mod install_graph; pub mod interactive; pub mod managed_install; diff --git a/src/main.rs b/src/main.rs index 71cd6454..17d10942 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::ffi::{OsStr, OsString}; + use zed_cli::asdf_environment; use zed_cli::auth; use zed_cli::cli::EnvCmd; @@ -10,6 +12,7 @@ use zed_cli::dev; use zed_cli::environment; use zed_cli::fetch; use zed_cli::git_submodules as submodules; +use zed_cli::global; use zed_cli::managed_install; use zed_cli::nix_bundle_write; use zed_cli::nix_export_plan; @@ -27,6 +30,27 @@ fn main() { eprintln!("error: {error:#}"); std::process::exit(2); } + if root_help_requested(&args) { + if let Err(error) = completion::print_root_help() { + eprintln!("error: {error:#}"); + std::process::exit(1); + } + return; + } + let global_requested = args.iter().skip(1).any(|argument| { + let argument = argument.as_os_str(); + argument == OsStr::new("global") || argument == OsStr::new("--global") + }); + if global_requested && let Some(result) = global::dispatch(args.clone()) { + match result { + Ok(0) => return, + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("error: {error:#}"); + std::process::exit(1); + } + } + } if let Some(result) = submodules::dispatch(args.clone()) { match result { Ok(0) => return, @@ -89,6 +113,50 @@ fn main() { } } +fn root_help_requested(args: &[OsString]) -> bool { + let mut index = 1; + while index < args.len() { + let token = args[index].to_string_lossy(); + if token == "--help" || token == "-h" { + return true; + } + if token == "help" { + return args + .iter() + .skip(index + 1) + .all(|argument| argument.to_string_lossy().starts_with('-')); + } + if root_global_option_takes_value(&token) { + index += if token.contains('=') { 1 } else { 2 }; + continue; + } + if token.starts_with('-') { + index += 1; + continue; + } + return false; + } + false +} + +fn root_global_option_takes_value(token: &str) -> bool { + const OPTIONS: &[&str] = &[ + "--registry", + "--home", + "--token", + "--auth-url", + "--supabase-url", + "--supabase-key", + "--global-bin-dir", + ]; + OPTIONS.iter().any(|option| { + token == *option + || token + .strip_prefix(option) + .is_some_and(|remainder| remainder.starts_with('=')) + }) +} + fn run(cli: Cli) -> anyhow::Result<()> { let cfg = Config::from_globals(&cli.globals)?; let git_submodules = cli.globals.git_submodules; diff --git a/src/ops.rs b/src/ops.rs index bd4b1cf9..907119b8 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -1063,6 +1063,52 @@ version = 1 assert_eq!(fs::read(&lock_path).unwrap(), original); } +fn locked_version_metadata(locked: &LockedPackage) -> VersionMetadata { + VersionMetadata { + org: locked.org.clone(), + name: locked.name.clone(), + version: locked.version.clone(), + sha256: locked.sha256.clone(), + size: locked.size, + format: locked.format, + vcs_tag: locked.vcs_tag.clone(), + vcs_commit: locked.vcs_commit.clone(), + // Never consumed while the store or verified cache owns the + // bytes. If local verification fails, ensure_artifact falls + // back to the configured registry and reports that failure. + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } +} + +#[cfg(test)] +#[test] +fn frozen_local_metadata_preserves_lock_identity_and_provenance() { + let locked = LockedPackage { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + sha256: "a".repeat(64), + size: 42, + format: zed_interfaces::artifact::ArtifactFormat::TarGz, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: Some("b".repeat(40)), + source: "https://registry.invalid".to_string(), + }; + let metadata = locked_version_metadata(&locked); + assert_eq!(metadata.org, locked.org); + assert_eq!(metadata.name, locked.name); + assert_eq!(metadata.version, locked.version); + assert_eq!(metadata.sha256, locked.sha256); + assert_eq!(metadata.size, locked.size); + assert_eq!(metadata.format, locked.format); + assert_eq!(metadata.vcs_tag, locked.vcs_tag); + assert_eq!(metadata.vcs_commit, locked.vcs_commit); + assert!(metadata.download_url.is_empty()); + assert!(!metadata.yanked); +} + /// Install body, called with the store lock already held. Split out so the /// build-hook path can install `[build-dependencies]` into a staging dir /// under the same lock without deadlocking on a re-acquire. @@ -1130,16 +1176,25 @@ fn install_locked( ); } require_sha256(&locked.sha256)?; - let vm = reg.get_version(&locked.org, &locked.name, &locked.version)?; - if vm.sha256 != locked.sha256 { - bail!( - "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", - locked.full_name(), - locked.version, - locked.sha256, - vm.sha256 - ); - } + let vm = if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() + { + // The lock already carries every immutable field needed + // to authenticate locally cached bytes. Avoid turning an + // exact frozen replay into a registry availability check. + locked_version_metadata(locked) + } else { + let vm = reg.get_version(&locked.org, &locked.name, &locked.version)?; + if vm.sha256 != locked.sha256 { + bail!( + "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", + locked.full_name(), + locked.version, + locked.sha256, + vm.sha256 + ); + } + vm + }; validate_version_metadata(&vm)?; resolved.insert(locked.full_name(), vm); } diff --git a/tests/global_cli.rs b/tests/global_cli.rs new file mode 100644 index 00000000..d7fa561c --- /dev/null +++ b/tests/global_cli.rs @@ -0,0 +1,248 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +fn run(args: &[&str], home: &Path, bin_dir: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_zed")) + .args(args) + .env("ZED_PKG_HOME", home) + .env("ZED_PKG_GLOBAL_BIN_DIR", bin_dir) + .env("ZED_PKG_REGISTRY", "file:///unused") + .env_remove("ZED_PKG_TOKEN") + .env_remove("ZED_PKG_INTERACTIVE") + .output() + .expect("run zed") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn profile_root(home: &Path) -> PathBuf { + home.join("global") + .join("profiles") + .join("acme") + .join("tool") +} + +fn add_profile(home: &Path, requested: &str, command: &str, bytes: &[u8]) -> PathBuf { + let root = profile_root(home); + let package_bin = root.join("zed_modules").join(".bin").join(command); + fs::create_dir_all(package_bin.parent().expect("profile bin parent")).unwrap(); + fs::write(&package_bin, bytes).unwrap(); + fs::write(root.join(".zpkg.lock"), "version = 1\n").unwrap(); + fs::write( + root.join(".zed-global-profile.json"), + serde_json::to_vec_pretty(&json!({ + "package": "acme/tool", + "requested": requested, + })) + .unwrap(), + ) + .unwrap(); + root +} + +fn sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(windows)] +fn destination_name(command: &str) -> String { + if command.to_ascii_lowercase().ends_with(".exe") { + command.to_string() + } else { + format!("{command}.exe") + } +} + +#[cfg(not(windows))] +fn destination_name(command: &str) -> String { + command.to_string() +} + +fn write_managed_state(home: &Path, destination: &str, owned_bytes: &[u8]) { + let global = home.join("global"); + fs::create_dir_all(&global).unwrap(); + let mut bins = Map::new(); + bins.insert( + destination.to_string(), + json!({ + "package": "acme/tool", + "sha256": sha256(owned_bytes), + }), + ); + fs::write( + global.join("managed-bins.json"), + serde_json::to_vec_pretty(&json!({ "bins": Value::Object(bins) })).unwrap(), + ) + .unwrap(); +} + +#[test] +fn root_help_composes_every_modular_command_family() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run(&["--help"], home.path(), &bin_dir); + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + for command in ["global", "fetch", "interop", "overtake", "develop"] { + assert!( + text.contains(command), + "root help omitted {command}: {text}" + ); + } +} + +#[test] +fn help_global_lists_the_complete_lifecycle_and_path_override() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run(&["help", "global"], home.path(), &bin_dir); + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + for command in ["install", "uninstall", "list", "bin-dir"] { + assert!( + text.contains(command), + "global help omitted {command}: {text}" + ); + } + assert!(text.contains("--global-bin-dir")); +} + +#[test] +fn global_bin_dir_uses_environment_and_cli_overrides() { + let home = tempfile::tempdir().expect("temporary home"); + let environment_bin = home.path().join("environment-bin"); + let output = run(&["global", "bin-dir"], home.path(), &environment_bin); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!( + stdout(&output).trim(), + environment_bin.display().to_string() + ); + + let cli_bin = home.path().join("cli-bin"); + let output = run( + &[ + "--global-bin-dir", + cli_bin.to_str().expect("UTF-8 test path"), + "global", + "bin-dir", + ], + home.path(), + &environment_bin, + ); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!(stdout(&output).trim(), cli_bin.display().to_string()); +} + +#[test] +fn empty_global_list_is_network_free_and_deterministic() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run(&["global", "list"], home.path(), &bin_dir); + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("no global package profiles installed")); + assert!(text.contains(&bin_dir.display().to_string())); +} + +#[test] +fn global_list_reads_an_offline_profile_and_its_commands() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + add_profile(home.path(), "acme/tool@^1", "acme-tool", b"tool bytes"); + + let output = run(&["global", "list"], home.path(), &bin_dir); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("acme/tool@unknown"), "{text}"); + assert!(text.contains("requested `acme/tool@^1`"), "{text}"); + assert!(text.contains("bins: acme-tool"), "{text}"); +} + +#[test] +fn install_global_alias_rejects_an_invalid_identity_before_network_io() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run( + &["install", "--global", "not-a-package-identity"], + home.path(), + &bin_dir, + ); + + assert!(!output.status.success()); + let error = stderr(&output); + assert!( + error.contains("invalid package spec"), + "unexpected error for invalid global package identity: {error}" + ); +} + +#[test] +fn uninstall_global_alias_rejects_versioned_selectors() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run( + &["uninstall", "--global", "acme/tool@1.0.0"], + home.path(), + &bin_dir, + ); + + assert!(!output.status.success()); + let error = stderr(&output); + assert!( + error.contains("selectors must be package identities without versions"), + "unexpected versioned-selector error: {error}" + ); +} + +#[test] +fn uninstall_removes_an_unchanged_zed_owned_command() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let profile = add_profile(home.path(), "acme/tool", "acme-tool", b"profile command"); + let destination_name = destination_name("acme-tool"); + let destination = bin_dir.join(&destination_name); + let owned = b"installed command"; + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(&destination, owned).unwrap(); + write_managed_state(home.path(), &destination_name, owned); + + let output = run(&["global", "uninstall", "acme/tool"], home.path(), &bin_dir); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(!profile.exists()); + assert!(!destination.exists()); +} + +#[test] +fn uninstall_preserves_a_command_modified_after_installation() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let profile = add_profile(home.path(), "acme/tool", "acme-tool", b"profile command"); + let destination_name = destination_name("acme-tool"); + let destination = bin_dir.join(&destination_name); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(&destination, b"user replacement").unwrap(); + write_managed_state(home.path(), &destination_name, b"original zed bytes"); + + let output = run(&["global", "uninstall", "acme/tool"], home.path(), &bin_dir); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(!profile.exists()); + assert_eq!(fs::read(&destination).unwrap(), b"user replacement"); + assert!( + stderr(&output).contains("changed after Zed installed it"), + "missing tamper-preservation warning: {}", + stderr(&output) + ); +}