diff --git a/Cargo.toml b/Cargo.toml index 0cf976b..f7e4f90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "anvil-env" -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["Alejandro Cabrera "] description = "A lightweight environment and configuration manager for VFX/Animation pipelines" @@ -36,6 +36,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } directories = "5.0" dirs = "5.0" shellexpand = "3.1" +shell-words = "1.1" which = "6.0" indexmap = { version = "2.0", features = ["serde"] } semver = { version = "1.0", features = ["serde"] } diff --git a/README.md b/README.md index 779ab9f..ee12b15 100644 --- a/README.md +++ b/README.md @@ -1,539 +1,328 @@ # Anvil -A fast, lightweight environment resolver for VFX and animation pipelines. Think Rez, but Rust-powered and simpler. +A fast environment resolver for VFX and animation pipelines. YAML package +definitions, instant resolution, single static binary. Think Rez, written in +Rust, with a small surface area. [![CI](https://github.com/voidreamer/anvil/actions/workflows/rust-ci.yml/badge.svg)](https://github.com/voidreamer/anvil/actions/workflows/rust-ci.yml) [![Release](https://img.shields.io/github/v/release/voidreamer/anvil?include_prereleases)](https://github.com/voidreamer/anvil/releases) -## Features - -- **YAML-based package definitions** -- simple, readable, version-controlled -- **Dependency resolution** -- automatic recursive resolution with version constraints (exact, minimum, range, alternatives) -- **Two package layouts** -- flat YAML files or nested `{name}/{version}/package.yaml` directories -- **Command aliases** -- packages define named commands; `anvil run` resolves them automatically -- **Environment variable expansion** -- `${VAR}`, `${PACKAGE_ROOT}`, `${VERSION}`, `${NAME}`, `~/` tilde expansion -- **Platform variants** -- per-platform requirements and environment overrides (Linux, macOS, Windows) -- **Aliases** -- named package sets for common configurations -- **Lockfiles** -- pin resolved versions for reproducible environments across machines -- **Saved contexts** -- export a fully resolved environment to JSON for render farms, CI, or sharing -- **Per-project config** -- `.anvil.yaml` in the project root, merged with user/studio config -- **Conflict detection** -- warns when packages silently override each other's variables -- **Pre/post hooks** -- run scripts before/after resolution or command execution (license checks, logging, etc.) -- **Package filters** -- include/exclude packages by glob pattern per-project or per-config -- **Shell completions** -- tab completion for bash, zsh, fish, PowerShell -- **Wrapper scripts** -- generate executable wrappers for resolved commands; add to `$PATH` for seamless tool access -- **Package publishing** -- `anvil publish` to copy validated packages to shared repositories -- **Scan caching** -- cached package scans with automatic invalidation for fast repeated calls -- **Cross-platform** -- Windows, Linux, macOS with shell-specific output (bash, zsh, fish, PowerShell, cmd) -- **Fast** -- written in Rust, resolves in milliseconds, single binary with no runtime dependencies - -## Installation +## Install ```bash cargo install anvil-env ``` -Or build from source: +Or build from source with `cargo build --release` and use `target/release/anvil`. -```bash -git clone https://github.com/voidreamer/anvil.git -cd anvil -cargo build --release -# Binary at target/release/anvil -``` - -## Quick Start +## Quick start -### 1. Create package definitions - -Anvil supports two layouts. Use whichever fits your workflow -- both can coexist in the same directory. - -**Flat files** (recommended for simplicity): - -```bash -mkdir -p ~/packages +Write a package at `~/packages/maya-2024.yaml`: -cat > ~/packages/maya-2024.yaml << 'EOF' +```yaml name: maya version: "2024" -description: Autodesk Maya 2024 - requires: - - python-3.10 - + - python-3.11 environment: - MAYA_VERSION: "2024" MAYA_LOCATION: /usr/autodesk/maya2024 PATH: ${MAYA_LOCATION}/bin:${PATH} - PYTHONPATH: ${PACKAGE_ROOT}/scripts:${PYTHONPATH} - commands: maya: ${MAYA_LOCATION}/bin/maya mayapy: ${MAYA_LOCATION}/bin/mayapy -EOF -``` - -**Nested directories** (useful when packages bundle scripts, addons, or other files): - -``` -~/packages/ - maya/ - 2024/ - package.yaml - scripts/ - modules/ - 2025/ - package.yaml ``` -### 2. Configure anvil - -Create `~/.anvil.yaml`: +Point anvil at it via `~/.anvil.yaml`: ```yaml package_paths: - ~/packages - - /studio/shared/packages - -default_shell: bash - -# Named package sets for common workflows -aliases: - maya-anim: - - maya-2024 - - animbot-2.0 - - studio-tools ``` -### 3. Use it +Then: ```bash -# Show resolved environment variables -anvil env maya-2024 - -# Launch maya using the command alias defined in the package -anvil run maya-2024 -- maya - -# Launch with multiple packages and arguments -anvil run maya-2024 arnold-7.2 -- maya -file scene.ma - -# Start an interactive shell with packages loaded -anvil shell maya-2024 arnold-7.2 - -# Use an alias to resolve a whole group of packages -anvil run maya-anim -- maya +anvil list # show available packages +anvil info maya-2024 # inspect one +anvil env maya-2024 # print resolved environment +anvil run maya-2024 -- maya # launch with alias resolution +anvil shell maya-2024 # interactive shell ``` -## Package Definition +The `commands` map lets `anvil run -- ` resolve the alias to +its expanded binary path, so you never repeat paths in wrappers or scripts. -A package is a YAML file that declares a name, version, and optionally: dependencies, environment variables, command aliases, and platform variants. +## Package definition -### Full schema +A package needs only a `name` and `version`. Everything else is optional. ```yaml -name: houdini # Required: package name -version: "20.5" # Required: version string -description: SideFX Houdini 20.5 # Optional: human-readable description +name: houdini +version: "20.5" +description: SideFX Houdini 20.5 -requires: # Optional: dependencies (version-constrained) +requires: - python-3.11 -environment: # Optional: environment variables to set - HOUDINI_VERSION: "${VERSION}" +environment: HFS: ${PACKAGE_ROOT} PATH: ${HFS}/bin:${PATH} PYTHONPATH: ${HFS}/python/lib/python3.11/site-packages:${PYTHONPATH} -commands: # Optional: named command aliases +commands: houdini: ${HFS}/bin/houdini hython: ${HFS}/bin/hython - hcustom: ${HFS}/bin/hcustom -variants: # Optional: platform-specific overrides +variants: - platform: linux environment: HFS: /opt/hfs20.5 - LD_LIBRARY_PATH: ${HFS}/dsolib:${LD_LIBRARY_PATH} - platform: macos environment: HFS: /Applications/Houdini/Houdini20.5/Frameworks/Houdini.framework/Versions/20.5/Resources - DYLD_LIBRARY_PATH: ${HFS}/../Libraries:${DYLD_LIBRARY_PATH} - - platform: windows - environment: - HFS: C:/Program Files/Side Effects Software/Houdini 20.5 ``` -### Minimal package +### Layouts -Only `name` and `version` are required: - -```yaml -name: studio-tools -version: "1.0" -environment: - PYTHONPATH: ${PACKAGE_ROOT}/python:${PYTHONPATH} -``` - -### Two package layouts - -**Flat files** -- YAML files directly in a package path. The filename is for your convenience; the `name` and `version` inside the file are what anvil uses: +Flat files live in the package directory as `-.yaml`: ``` ~/packages/ maya-2024.yaml arnold-7.2.yaml - python-3.11.yaml ``` -**Nested directories** -- the traditional `{name}/{version}/package.yaml` layout. Better when a package includes associated files (scripts, addons, libraries): +Nested directories live as `//package.yaml` and are better when +the package ships associated files: ``` ~/packages/ - maya/ - 2024/ - package.yaml - scripts/ - modules/ - arnold/ - 7.2/ - package.yaml - bin/ - lib/ + maya/2024/ + package.yaml + scripts/ + modules/ ``` -Both layouts can coexist in the same package path directory. Anvil scans `.yaml`/`.yml` files as flat packages and subdirectories as nested packages in a single pass. +Both layouts can coexist in one package path. ### Version constraints -Used in the `requires` field and when requesting packages from the CLI: - -| Format | Meaning | -|--------|---------| -| `maya-2024` | Exactly version 2024 | -| `maya-2024+` | Version 2024 or higher | -| `maya-2024..2025` | Versions 2024 through 2025 (inclusive) | -| `python-3.10\|3.11` | Version 3.10 or 3.11 | -| `maya` | Any available version (highest wins) | +Used inside `requires` and at the CLI. -When multiple versions match a constraint, the highest version is selected. Versions are compared as semantic versions when possible, falling back to string comparison. +| Form | Meaning | +|---|---| +| `maya-2024` | exactly 2024 | +| `maya-2024+` | 2024 or higher | +| `maya-2024..2025` | 2024 through 2025 inclusive | +| `python-3.10\|3.11` | 3.10 or 3.11 | +| `maya` | any version, highest wins | -Package names and versions are split on the last `-` in the request string, but only when the suffix starts with a digit. This means hyphenated names like `studio-blender-tools` work correctly -- anvil treats the whole string as the name and resolves any version. To request a specific version: `studio-blender-tools-1.0.0`. +Names with internal hyphens work (`studio-blender-tools-1.0.0`); anvil splits +only on the last hyphen when the suffix starts with a digit. -### Environment variable expansion +### Environment expansion -Package environment values are expanded in this order: - -1. `${PACKAGE_ROOT}` -- absolute path to the package directory -2. `${VERSION}` -- the package's version string -3. `${NAME}` -- the package's name -4. `${ANY_VAR}` -- any variable from previously resolved packages or the current environment -5. `~/` prefix -- expanded to the user's home directory - -When multiple packages are resolved, their environments are merged in dependency order. Each package sees the environment from all previously resolved packages, so later packages can reference variables set by earlier ones. - -**Conflict detection:** If two packages both set the same variable and the later one does not reference `${VAR}` (i.e., it overwrites rather than appends), anvil emits a warning. This catches accidental overrides while allowing common append patterns like `PATH: .../bin:${PATH}`. +Values resolve in this order: `${PACKAGE_ROOT}`, `${VERSION}`, `${NAME}`, then +any `${VAR}` set by previously resolved packages or the inherited environment, +and finally a leading `~/`. When two packages set the same variable without +referencing `${VAR}` on the right, anvil emits a conflict warning so a silent +overwrite does not slip through. ### Command aliases -Packages can define named commands in the `commands` field: +The `commands` map lets `anvil run` pick a program from the package definition. +Values expand the same way as `environment` values, and can include baked in +arguments with whitespace or tilde segments: ```yaml commands: - houdini: ${HFS}/bin/houdini - hython: ${HFS}/bin/hython - kick: ${PACKAGE_ROOT}/bin/kick + nukex: ${NUKE_HOME}/Nuke${VERSION} --nukex + usdview: python3.14 ~/USD/bin/usdview ``` -When you use `anvil run`, the first argument after `--` is looked up in the merged command map of all resolved packages. If it matches a defined alias, it's replaced with the fully expanded path before execution: - -```bash -# "houdini" is resolved to /opt/hfs20.5/bin/houdini (or platform equivalent) -anvil run houdini-20.5 -- houdini -scene myfile.hip - -# Commands that don't match any alias pass through unchanged -anvil run houdini-20.5 -- /usr/bin/env hython myscript.py -``` - -Command values support the same variable expansion as environment values (`${PACKAGE_ROOT}`, `${VERSION}`, etc.), and are expanded against the fully resolved environment. - -### Platform variants - -Variants apply platform-specific overrides. The `requires` list is extended (merged with the base list), and `environment` values overwrite the base values for matching keys. - -Supported platforms: `linux`, `windows`, `macos`. - -```yaml -variants: - - platform: linux - requires: - - gcc-11 - environment: - LD_LIBRARY_PATH: ${PACKAGE_ROOT}/lib:${LD_LIBRARY_PATH} - - platform: macos - requires: - - clang-14 - - platform: windows - requires: - - msvc-2022 - environment: - PATH: ${PACKAGE_ROOT}/bin;${PATH} -``` +Anvil tokenises the value with POSIX shell rules, expands `~/` in every token, +then runs the first token with the remaining tokens prepended to whatever the +user passes after `--`. ## Commands +All twelve commands at a glance. + ### `anvil env` -Resolve packages and print the resulting environment. +Print the resolved environment. ```bash -anvil env maya-2024 arnold-7.2 # KEY=VALUE format -anvil env maya-2024 --export # Shell export statements -anvil env maya-2024 --json # JSON object +anvil env maya-2024 # KEY=VALUE +anvil env maya-2024 --export # shell export lines +anvil env maya-2024 --json # JSON object ``` -Useful for debugging, piping into other tools, or generating env files for IDE integration. - ### `anvil run` -Run a command with the resolved environment. If the command name matches a [command alias](#command-aliases) defined by any resolved package, it's automatically expanded to the full path. +Run a command with the resolved environment. The first token after `--` is +looked up in the merged `commands` map. ```bash -# "maya" is resolved from the package's commands: field anvil run maya-2024 -- maya - -# Multiple packages, with arguments passed through anvil run maya-2024 arnold-7.2 -- maya -file scene.ma - -# Add extra environment variables with -e -anvil run maya-2024 -e MAYA_DEBUG=1 -e CUSTOM=value -- maya +anvil run maya-2024 -e MAYA_DEBUG=1 -- maya ``` Exits with the command's exit code. ### `anvil shell` -Start an interactive shell with packages loaded. Adds `[anvil]` to the prompt. +Start an interactive shell with packages loaded. On Unix the shell replaces the +current process. ```bash anvil shell maya-2024 arnold-7.2 anvil shell maya-2024 --shell zsh ``` -Shell detection priority: `--shell` flag > `default_shell` from config > `$SHELL` > `bash`. - -On Unix, the shell replaces the current process (`exec`). On Windows, it spawns a child process and waits. - ### `anvil list` -List available packages or versions of a specific package. - ```bash -anvil list # All package names -anvil list maya # All versions of maya +anvil list # all package names +anvil list maya # versions of one package ``` ### `anvil info` -Show details for a specific package: name, version, description, dependencies, environment, and commands. +Show one package's metadata, environment, and commands map. ```bash anvil info maya-2024 -anvil info houdini-20.5 ``` ### `anvil validate` -Check that package definitions are valid and all dependencies can be resolved. +Check that package definitions parse and resolve. ```bash -anvil validate # Validate all packages -anvil validate maya-2024 # Validate one package +anvil validate # all packages +anvil validate maya-2024 # one package ``` ### `anvil lock` -Resolve packages and pin the exact versions to `anvil.lock`. Subsequent `anvil env`, `run`, and `shell` commands will prefer locked versions when the lockfile is present. +Pin resolved versions to `anvil.lock` for reproducible environments. Subsequent +`anvil env`, `run`, and `shell` prefer the pinned versions. ```bash -# Create a lockfile anvil lock maya-2024 arnold-7.2 - -# Now any resolution will use pinned versions -anvil env maya-2024 # uses versions from anvil.lock -anvil run maya-2024 -- maya # same - -# Re-resolve and update the lockfile anvil lock maya-2024 arnold-7.2 --update ``` -The lockfile is a YAML file that can be committed to version control for reproducible environments across the team. +The lockfile is YAML. Commit it alongside the project for team wide +reproducibility. ### `anvil context` -Save a fully resolved environment to a JSON file. The context can be loaded later to run commands or start shells without re-resolving -- useful for render farms, CI pipelines, or sharing exact environments. +Freeze a fully resolved environment to JSON so render farms, CI, or other +machines can re-enter it without re-resolving. ```bash -# Save a context anvil context save maya-2024 arnold-7.2 -o render.ctx.json - -# Inspect it anvil context show render.ctx.json anvil context show render.ctx.json --json anvil context show render.ctx.json --export - -# Run a command using the saved environment anvil context run render.ctx.json -- maya -batch -file scene.ma - -# Start a shell with the saved environment anvil context shell render.ctx.json ``` ### `anvil init` -Scaffold a new package definition with a template. +Scaffold a new package definition. ```bash -# Create a nested package directory -anvil init my-tools # my-tools/1.0.0/package.yaml -anvil init my-tools --version 2.0 # my-tools/2.0/package.yaml - -# Create a flat YAML file -anvil init my-tools --flat # my-tools-1.0.0.yaml +anvil init my-tools # my-tools/1.0.0/package.yaml +anvil init my-tools --version 2.0 # my-tools/2.0/package.yaml +anvil init my-tools --flat # my-tools-1.0.0.yaml ``` ### `anvil completions` -Generate shell completions for tab completion. +Generate shell completions. Evaluate the output in your shell rc file. ```bash -# Bash (add to ~/.bashrc) eval "$(anvil completions bash)" - -# Zsh (add to ~/.zshrc) eval "$(anvil completions zsh)" - -# Fish anvil completions fish | source - -# PowerShell anvil completions powershell | Out-String | Invoke-Expression ``` ### `anvil wrap` -Generate executable wrapper scripts for all commands defined by the resolved packages. Each wrapper calls `anvil run` under the hood, so it always resolves correctly (respects lockfiles, config, etc.). +Create executable wrapper scripts for every command in a set of resolved +packages. The wrappers call `anvil run` internally, so they always respect +lockfiles, project configs, and command aliases. Drop the output directory on +`$PATH` and artists call the tools directly. ```bash -# Generate wrappers for all commands in houdini + its dependencies anvil wrap houdini-20.5 --dir ~/tools/fx -# Creates: ~/tools/fx/houdini, ~/tools/fx/hython, ~/tools/fx/python, ... - -# Add to PATH and use directly export PATH="$HOME/tools/fx:$PATH" houdini -scene myfile.hip ``` -This is how you create **suites** -- generate wrappers for a department's tool set, add the directory to `$PATH`, and artists get seamless access to all tools without knowing about anvil. - ### `anvil publish` -Copy a validated package to a shared package repository. +Copy a validated package to a shared repository. Refuses to overwrite. ```bash -# Publish from a package directory (nested layout) anvil publish /studio/packages --path ~/dev/my-tool - -# Publish as a flat YAML file anvil publish /studio/packages --path ~/dev/my-tool --flat - -# Publish from current directory -cd ~/dev/my-tool -anvil publish /studio/packages ``` -Refuses to overwrite existing packages. Validates the package before copying. - ## Configuration ### Global config -Anvil looks for a global (user-level) configuration in this order: +Anvil reads configuration in this order: -1. `$ANVIL_CONFIG` environment variable (if set) +1. `$ANVIL_CONFIG` environment variable 2. `~/.anvil.yaml` 3. `~/.config/anvil/config.yaml` -If no config file is found, anvil uses default package paths (see below). - -### Per-project config - -Anvil also searches for a `.anvil.yaml` file in the current directory and its ancestors. If found, the project config is merged with the global config: - -- **`package_paths`** -- project paths are **prepended** (higher priority than global) -- **`aliases`** -- project aliases are added; same-name aliases override the global ones -- **`default_shell`** -- project value wins if set -- **`platform`** -- project platform paths are prepended per-platform +### Project config -This allows each project/show to define its own package locations and aliases without modifying the user's global config: +Anvil also walks the current directory and its parents looking for +`.anvil.yaml`. When found, it is merged with the global config. `package_paths` +from the project are prepended, aliases with the same name override globals, +`default_shell` wins if the project sets it, and per-platform paths are +prepended per-platform. -```yaml -# /projects/myshow/.anvil.yaml -package_paths: - - /projects/myshow/packages - -aliases: - show-tools: - - maya-2024 - - myshow-assets-1.0 - - myshow-pipeline-2.3 -``` - -### Full config example +### Full example ```yaml -# Package search paths (supports ${VAR} expansion and ~/ tilde expansion) package_paths: - - ~/packages # Local/dev packages (highest priority) - - /studio/packages # Shared studio packages - - ${STUDIO_ROOT}/packages # Variable-based paths + - ~/packages + - /studio/packages + - ${STUDIO_ROOT}/packages -# Default shell for 'anvil shell' (optional, defaults to $SHELL or bash) default_shell: zsh -# Named package sets (optional) -# Use as: anvil run maya-anim -- maya aliases: maya-anim: - maya-2024 - animbot-2.0 - studio-tools - maya-light: - - maya-2024 - - arnold-7.2 - - light-tools studio-blender: - blender-4.2 - studio-blender-tools - - studio-python -# Platform-specific additional package paths (optional) -# These extend the base package_paths list on matching platforms platform: linux: - package_paths: - - /mnt/shared/packages + package_paths: [/mnt/shared/packages] windows: - package_paths: - - P:/packages + package_paths: [P:/packages] macos: - package_paths: - - /Volumes/shared/packages + package_paths: [/Volumes/shared/packages] -# Lifecycle hooks (optional) -# Shell commands run at specific points during resolution/execution hooks: pre_resolve: - - echo "Resolving packages..." + - echo "resolving..." post_resolve: - /studio/scripts/log_resolution.sh pre_run: @@ -541,137 +330,72 @@ hooks: post_run: - /studio/scripts/cleanup.sh -# Package filters (optional) -# When include is set, only matching packages are visible -# Exclude is applied after include. Patterns use glob syntax (*, ?) filters: - include: - - "maya-*" - - "arnold-*" - - "studio-*" - exclude: - - "*-dev" - - "test-*" + include: ["maya-*", "arnold-*", "studio-*"] + exclude: ["*-dev", "test-*"] ``` ### Hooks -Hooks are shell commands that run at specific lifecycle points: - -| Hook | When | Fails on error? | -|------|------|-----------------| -| `pre_resolve` | Before package resolution | Yes | -| `post_resolve` | After resolution, with resolved env | Yes | -| `pre_run` | Before command execution in `anvil run` | Yes | -| `post_run` | After command finishes in `anvil run` | No (best-effort) | +Shell commands run at lifecycle points. A non zero exit from any `pre_` hook +aborts the operation; `post_run` is best effort and never aborts. -If a pre-hook exits non-zero, the operation is aborted. Hooks receive the resolved environment as their environment. +| Hook | When | +|---|---| +| `pre_resolve` | before package resolution | +| `post_resolve` | after resolution with the resolved env | +| `pre_run` | before `anvil run` executes the command | +| `post_run` | after `anvil run` finishes | -### Package filters +### Filters -Filters control which packages are visible. Useful for restricting a project to only approved packages: +Limit which packages are visible. Patterns support `*` and `?`. ```yaml -# Only show Maya and Arnold packages, but hide dev versions filters: include: ["maya-*", "arnold-*"] exclude: ["*-dev"] ``` -When `include` is non-empty, only matching packages pass. `exclude` is applied after `include`. Patterns support `*` (any chars) and `?` (single char). - ### Environment variables | Variable | Purpose | -|----------|---------| -| `ANVIL_CONFIG` | Override config file location | -| `ANVIL_PACKAGES` | Additional package paths (colon-separated) | -| `RUST_LOG` | Control log verbosity (e.g., `RUST_LOG=debug anvil env maya`) | - -### Default package paths +|---|---| +| `ANVIL_CONFIG` | override config file location | +| `ANVIL_PACKAGES` | additional package paths, colon separated | +| `RUST_LOG` | log verbosity, e.g. `RUST_LOG=debug` | -If no config file is found, anvil searches these directories for packages: - -- Paths from `$ANVIL_PACKAGES` (colon-separated) -- `$HOME/packages` -- `$HOME/.local/share/anvil/packages` -- `/opt/packages` +If no config file is found, anvil falls back to `$ANVIL_PACKAGES`, +`$HOME/packages`, `$HOME/.local/share/anvil/packages`, and `/opt/packages`. ## Anvil vs Rez -Anvil is designed as a practical alternative to [Rez](https://github.com/AcademySoftwareFoundation/rez) for studios that need fast, reliable environment resolution without the operational overhead. +Anvil targets the same problem as [Rez](https://github.com/AcademySoftwareFoundation/rez) +with a smaller surface area and no Python runtime. | | Anvil | Rez | |---|---|---| -| **Language** | Rust (single static binary) | Python | -| **Startup time** | Milliseconds | Seconds (Python bootstrap + imports) | -| **Package format** | YAML | Python (`package.py`) | -| **Runtime dependencies** | None | Python 3.7+, pip, platform bindings | -| **Resolution strategy** | Greedy (highest matching version) | SAT solver with backtracking | -| **Installation** | `cargo install anvil-env` or download binary | pip install + `rez bind` + config | -| **Learning curve** | 6 commands, YAML only | Many subsystems, Python API | -| **Config surface** | 1 YAML file, 3 env vars | Multiple config files, dozens of settings | - -### Where Anvil shines - -- **Zero bootstrap** -- no Python runtime, no virtual environments, no `rez bind`, no platform bindings. Copy the binary and go. -- **Instant startup** -- sub-millisecond resolution means no lag when launching tools or shells. Artists don't wait. -- **Simple packages** -- YAML files that any TD can write, review in PRs, and store alongside code in version control. -- **Low ops burden** -- a single binary and a directory of YAML files. No database, no daemon, no package server required. -- **Flat-file packages** -- for simple packages (wrappers, environment configs), a single YAML file is enough. No directory hierarchy needed. -- **Cross-platform first** -- native Windows, Linux, macOS support with per-platform variants and shell-specific output (bash, zsh, fish, PowerShell, cmd). - -### Where Rez has the edge (for now) - -- **SAT solver** -- handles complex constraint satisfaction that greedy resolution cannot (important for large dependency graphs with conflicts). -- **Build system** -- `rez-build` / `rez-release` for building compiled packages with cmake/make integration. -- **Package repository** -- centralized package server with memcached integration for cached resolution. - -### Who should use Anvil - -Anvil is built for studios and teams where Rez's complexity isn't justified by the scale of the dependency graph. If your packages number in the dozens (not thousands), if your version constraints are straightforward, and if you value fast iteration and simple ops over advanced constraint solving -- Anvil is the right tool. - -This includes solo TDs, small studios, and medium studios that want to ship a working pipeline without dedicating engineering time to maintaining a package management system. - -## Roadmap - -Planned features, roughly in priority order. Contributions welcome. - -### Near term - -- [x] **`anvil context`** -- save and restore resolved environments to a file (like `rez-env --output`) -- [x] **Lockfiles** -- pin resolved versions for reproducible environments across machines and CI -- [x] **Per-project config** -- `.anvil.yaml` in the project root, merged with user/studio config -- [x] **Conflict warnings** -- detect and warn when multiple packages set the same environment variable -- [x] **`anvil init`** -- scaffold a new package definition from a template -- [x] **Resolution caching** -- cache filesystem scan results to skip re-traversal on repeated calls -- [x] **Pre/post hooks** -- run scripts before or after resolution, shell entry, or command execution -- [x] **Package filters** -- include or exclude packages by pattern, label, or path -- [x] **Shell completions** -- tab completion for bash, zsh, fish, PowerShell - -### Medium term - -- [x] **`anvil publish`** -- publish validated packages to shared package repositories -- [x] **`anvil wrap`** -- generate wrapper scripts for resolved commands; create tool suites for departments -- [ ] **Remote package sources** -- fetch packages from HTTP, S3, or GCS endpoints - -### Longer term - -- [ ] **Backtracking resolver** -- upgrade to a solver that backtracks on conflicts for complex dependency graphs -- [ ] **Package server** -- lightweight HTTP service for centralized package hosting and discovery -- [ ] **Standalone wrappers** -- wrapper scripts that embed the environment (no anvil needed at runtime) -- [ ] **Web dashboard** -- visibility into available packages, resolution results, and usage across the studio -- [ ] **Audit logging** -- track who resolved what, when, for compliance and debugging +| Language | Rust, single static binary | Python | +| Startup | milliseconds | seconds (Python bootstrap) | +| Package format | YAML | `package.py` | +| Runtime deps | none | Python 3.7+, pip, platform bindings | +| Resolver | greedy | SAT solver with backtracking | +| Install | `cargo install anvil-env` | pip install plus `rez bind` plus config | +| Config surface | one YAML file, three env vars | many files, dozens of settings | + +Anvil suits solo TDs and small to medium studios whose packages number in the +dozens rather than thousands, where fast iteration and simple operations matter +more than advanced constraint solving. Rez keeps the edge on large dependency +graphs with complex conflicts, built in build and release tooling, and a +centralised package server. ## Development ```bash -cargo build --release # Optimized binary (LTO, stripped) -cargo test # Run tests -cargo fmt # Format code -cargo clippy # Lint - -# Debug logging +cargo build --release +cargo test +cargo fmt +cargo clippy RUST_LOG=debug cargo run -- env maya-2024 ``` diff --git a/src/main.rs b/src/main.rs index 5664178..5bb9e42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ //! //! A fast, lightweight alternative to Rez for managing DCC environments. -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -138,18 +138,38 @@ fn cmd_run( anyhow::bail!("No command specified"); } - // Resolve command alias + // Resolve command alias. A command value may include baked-in + // arguments (e.g. `nukex: ${NUKE}/Nuke --nukex`) or whitespace from + // a script launcher (e.g. `usdview: python3.14 ~/USD/bin/usdview`). + // Tokenize with POSIX shell rules so the first token is the program + // and the rest are prepended to user args. Tilde-expand each token + // individually — Package::expand_env_value only expands a leading + // `~/` so tokens after whitespace would otherwise stay literal. let commands_map = resolved.commands(); - let executable = commands_map + let resolved_cmd = commands_map .get(&command[0]) .cloned() .unwrap_or_else(|| command[0].clone()); + let mut tokens: Vec = shell_words::split(&resolved_cmd) + .with_context(|| format!("Failed to parse command alias: {:?}", resolved_cmd))? + .into_iter() + .map(|t| shellexpand::tilde(&t).into_owned()) + .collect(); + if tokens.is_empty() { + anyhow::bail!( + "Command alias for {:?} resolved to an empty string", + command[0] + ); + } + let executable = tokens.remove(0); + let mut all_args = tokens; + all_args.extend(command[1..].iter().cloned()); // Pre-run hooks Config::run_hooks(&config.hooks.pre_run, &env)?; let status = Command::new(&executable) - .args(&command[1..]) + .args(&all_args) .envs(&env) .status()?; diff --git a/tests/cli.rs b/tests/cli.rs index f36024b..1d2b77d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -221,6 +221,102 @@ fn run_without_command_fails() { .failure(); } +#[test] +fn run_multi_token_command_alias() { + // A command alias whose value has whitespace (program + baked-in + // args) must be split so the user's extra args land after the + // baked-in ones. Regression for issue where the whole string was + // passed to Command::new() as a single filename. + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("greeter-1.0.yaml"), + "name: greeter\nversion: \"1.0\"\ncommands:\n greet: /bin/echo hello from\n", + ) + .unwrap(); + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + + anvil(&config_path.to_string_lossy()) + .args(["run", "greeter-1.0", "--", "greet", "world"]) + .assert() + .success() + .stdout(predicate::str::contains("hello from world")); +} + +#[test] +fn run_tilde_expands_in_each_token() { + // ~/ should expand in every token, not just when it's the leading + // character of the whole resolved value. Regression for aliases like: + // usdview: python3.14 ~/USD/bin/usdview + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + + // Make a real target file under $HOME so ~/ expansion is observable. + // We write to $TMPDIR-style path the test controls by overriding HOME. + let fake_home = dir.path().join("home"); + fs::create_dir_all(fake_home.join("bin")).unwrap(); + let script = fake_home.join("bin/ping.sh"); + fs::write(&script, "#!/bin/bash\necho PONG\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).unwrap(); + } + + fs::write( + pkg_dir.join("pingpkg-1.0.yaml"), + "name: pingpkg\nversion: \"1.0\"\ncommands:\n ping: /bin/bash ~/bin/ping.sh\n", + ) + .unwrap(); + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + + anvil(&config_path.to_string_lossy()) + .env("HOME", fake_home.to_str().unwrap()) + .args(["run", "pingpkg-1.0", "--", "ping"]) + .assert() + .success() + .stdout(predicate::str::contains("PONG")); +} + +#[test] +fn run_quoted_tokens_in_alias() { + // Quoted substrings in an alias must stay as a single argv element. + let dir = TempDir::new().unwrap(); + let pkg_dir = dir.path().join("packages"); + fs::create_dir_all(&pkg_dir).unwrap(); + fs::write( + pkg_dir.join("qtest-1.0.yaml"), + "name: qtest\nversion: \"1.0\"\ncommands:\n say: /bin/echo \"hello world\"\n", + ) + .unwrap(); + let config_path = dir.path().join("config.yaml"); + fs::write( + &config_path, + format!("package_paths:\n - {}\n", pkg_dir.display()), + ) + .unwrap(); + + anvil(&config_path.to_string_lossy()) + .args(["run", "qtest-1.0", "--", "say"]) + .assert() + .success() + .stdout(predicate::str::contains("hello world")); +} + // ---- flat file + nested coexistence ---- #[test]