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/.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/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/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/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)); +} 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) + ); +}