diff --git a/docs/.vitepress/cli_commands.ts b/docs/.vitepress/cli_commands.ts index ba111fdaa5..12b8a787f3 100644 --- a/docs/.vitepress/cli_commands.ts +++ b/docs/.vitepress/cli_commands.ts @@ -210,6 +210,9 @@ export const commands: { [key: string]: Command } = { }, }, }, + prepare: { + hide: false, + }, prune: { hide: false, }, diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 846fe691fc..61ae294c13 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -67,6 +67,7 @@ export default withMermaid( { text: "Tool Stubs", link: "/dev-tools/tool-stubs" }, { text: "Registry", link: "/registry" }, { text: "mise.lock Lockfile", link: "/dev-tools/mise-lock" }, + { text: "Prepare", link: "/dev-tools/prepare" }, { text: "Backend Architecture", link: "/dev-tools/backend_architecture", diff --git a/docs/cli/exec.md b/docs/cli/exec.md index a37d5b466a..c123f4b5cd 100644 --- a/docs/cli/exec.md +++ b/docs/cli/exec.md @@ -36,6 +36,10 @@ Command string to execute Number of jobs to run in parallel [default: 4] +### `--no-prepare` + +Skip automatic dependency preparation + ### `--raw` Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1 diff --git a/docs/cli/index.md b/docs/cli/index.md index e1530e4223..aaac5a215e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -119,6 +119,7 @@ Can also use `MISE_NO_CONFIG=1` - [`mise plugins ls-remote [-u --urls] [--only-names]`](/cli/plugins/ls-remote.md) - [`mise plugins uninstall [-a --all] [-p --purge] [PLUGIN]…`](/cli/plugins/uninstall.md) - [`mise plugins update [-j --jobs ] [PLUGIN]…`](/cli/plugins/update.md) +- [`mise prepare [FLAGS]`](/cli/prepare.md) - [`mise prune [FLAGS] [INSTALLED_TOOL]…`](/cli/prune.md) - [`mise registry [FLAGS] [NAME]`](/cli/registry.md) - [`mise reshim [-f --force]`](/cli/reshim.md) diff --git a/docs/cli/prepare.md b/docs/cli/prepare.md new file mode 100644 index 0000000000..5a12f94881 --- /dev/null +++ b/docs/cli/prepare.md @@ -0,0 +1,71 @@ + +# `mise prepare` + +- **Usage**: `mise prepare [FLAGS]` +- **Aliases**: `prep` +- **Source code**: [`src/cli/prepare.rs`](https://github.com/jdx/mise/blob/main/src/cli/prepare.rs) + +[experimental] Ensure project dependencies are ready + +Runs all applicable prepare steps for the current project. +This checks if dependency lockfiles are newer than installed outputs +(e.g., package-lock.json vs node_modules/) and runs install commands +if needed. + +Providers with `auto = true` are automatically invoked before `mise x` and `mise run` +unless skipped with the --no-prepare flag. + +## Flags + +### `-f --force` + +Force run all prepare steps even if outputs are fresh + +### `--list` + +Show what prepare steps are available + +### `-n --dry-run` + +Only check if prepare is needed, don't run commands + +### `--only… ` + +Run specific prepare rule(s) only + +### `--skip… ` + +Skip specific prepare rule(s) + +Examples: + +``` +mise prepare # Run all applicable prepare steps +mise prepare --dry-run # Show what would run without executing +mise prepare --force # Force run even if outputs are fresh +mise prepare --list # List available prepare providers +mise prepare --only npm # Run only npm prepare +mise prepare --skip npm # Skip npm prepare +``` + +Configuration: + +``` +Configure prepare providers in mise.toml: + +```toml +# Built-in npm provider (auto-detects lockfile) +[prepare.npm] +auto = true # Auto-run before mise x/run + +# Custom provider +[prepare.codegen] +auto = true +sources = ["schema/*.graphql"] +outputs = ["src/generated/"] +run = "npm run codegen" + +[prepare] +disable = ["npm"] # Disable specific providers at runtime +``` +``` diff --git a/docs/cli/run.md b/docs/cli/run.md index 6720b83329..a717bf4c37 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -101,6 +101,10 @@ Tool(s) to run in addition to what is in mise.toml files e.g.: node@20 python@3. Do not use cache on remote tasks +### `--no-prepare` + +Skip automatic dependency preparation + ### `--no-timings` Hides elapsed time after each task completes diff --git a/docs/cli/tasks/run.md b/docs/cli/tasks/run.md index 8e1677dc5a..dbd0c4b679 100644 --- a/docs/cli/tasks/run.md +++ b/docs/cli/tasks/run.md @@ -115,6 +115,10 @@ Tool(s) to run in addition to what is in mise.toml files e.g.: node@20 python@3. Do not use cache on remote tasks +### `--no-prepare` + +Skip automatic dependency preparation + ### `--no-timings` Hides elapsed time after each task completes diff --git a/docs/dev-tools/prepare.md b/docs/dev-tools/prepare.md new file mode 100644 index 0000000000..69e249be00 --- /dev/null +++ b/docs/dev-tools/prepare.md @@ -0,0 +1,200 @@ +# Prepare + +The `mise prepare` command ensures project dependencies are ready by checking if lockfiles +are newer than installed outputs (e.g., `package-lock.json` vs `node_modules/`) and running +install commands if needed. + +## Quick Start + +```bash +# Enable experimental features +export MISE_EXPERIMENTAL=1 + +# Run all applicable prepare steps +mise prepare + +# Or use the alias +mise prep +``` + +## Configuration + +Configure prepare providers in `mise.toml`: + +```toml +# Built-in npm provider (auto-detects lockfile) +[prepare.npm] +auto = true # Auto-run before mise x/run + +# Built-in providers for other package managers +[prepare.yarn] +[prepare.pnpm] +[prepare.bun] +[prepare.go] +[prepare.pip] +[prepare.poetry] +[prepare.uv] +[prepare.bundler] +[prepare.composer] + +# Custom provider +[prepare.codegen] +auto = true +sources = ["schema/*.graphql"] +outputs = ["src/generated/"] +run = "npm run codegen" + +# Disable specific providers +[prepare] +disable = ["npm"] +``` + +## Built-in Providers + +mise includes built-in providers for common package managers: + +| Provider | Lockfile | Output | Command | +| ---------- | ------------------------- | --------------------- | ------------------------------------ | +| `npm` | `package-lock.json` | `node_modules/` | `npm install` | +| `yarn` | `yarn.lock` | `node_modules/` | `yarn install` | +| `pnpm` | `pnpm-lock.yaml` | `node_modules/` | `pnpm install` | +| `bun` | `bun.lockb` or `bun.lock` | `node_modules/` | `bun install` | +| `go` | `go.mod` | `vendor/` or `go.sum` | `go mod vendor` or `go mod download` | +| `pip` | `requirements.txt` | `.venv/` | `pip install -r requirements.txt` | +| `poetry` | `poetry.lock` | `.venv/` | `poetry install` | +| `uv` | `uv.lock` | `.venv/` | `uv sync` | +| `bundler` | `Gemfile.lock` | `vendor/bundle/` | `bundle install` | +| `composer` | `composer.lock` | `vendor/` | `composer install` | + +Built-in providers are only active when explicitly configured in `mise.toml` and their lockfile exists. + +## Custom Providers + +Create custom providers for project-specific build steps: + +```toml +[prepare.codegen] +sources = ["schema/*.graphql", "codegen.yml"] +outputs = ["src/generated/"] +run = "npm run codegen" +description = "Generate GraphQL types" + +[prepare.prisma] +sources = ["prisma/schema.prisma"] +outputs = ["node_modules/.prisma/"] +run = "npx prisma generate" +``` + +### Provider Options + +| Option | Type | Description | +| ------------- | -------- | -------------------------------------------------------- | +| `auto` | bool | Auto-run before `mise x` and `mise run` (default: false) | +| `sources` | string[] | Files/patterns to check for changes | +| `outputs` | string[] | Files/directories that should be newer than sources | +| `run` | string | Command to run when stale | +| `env` | table | Environment variables to set | +| `dir` | string | Working directory for the command | +| `description` | string | Description shown in output | + +## Freshness Checking + +mise uses modification time (mtime) comparison to determine if outputs are stale: + +1. Find the most recent mtime among all source files +2. Find the most recent mtime among all output files +3. If any source is newer than all outputs, the provider is stale + +This means: + +- If you modify `package-lock.json`, `node_modules/` will be considered stale +- If `node_modules/` doesn't exist, the provider is always stale +- If sources don't exist, the provider is considered fresh (nothing to do) + +## Auto-Prepare + +When `auto = true` is set on a provider, it will automatically run before: + +- `mise run` (task execution) +- `mise x` (exec command) + +This ensures dependencies are always up-to-date before running tasks or commands. + +To skip auto-prepare for a single invocation: + +```bash +mise run --no-prepare build +mise x --no-prepare -- npm test +``` + +## Staleness Warnings + +When using `mise activate`, mise will warn you if any auto-enabled providers have stale dependencies: + +``` +mise WARN prepare: npm may need update, run `mise prep` +``` + +This can be disabled with: + +```toml +[settings] +status.show_prepare_stale = false +``` + +## CLI Usage + +```bash +# Run all applicable prepare steps +mise prepare + +# Show what would run without executing +mise prepare --dry-run + +# Force run even if outputs are fresh +mise prepare --force + +# List available prepare providers +mise prepare --list + +# Run only specific providers +mise prepare --only npm --only codegen + +# Skip specific providers +mise prepare --skip npm +``` + +## Parallel Execution + +Prepare providers run in parallel, respecting the `jobs` setting for concurrency limits. +This speeds up preparation when multiple providers need to run (e.g., both npm and pip). + +```toml +[settings] +jobs = 4 # Run up to 4 providers in parallel +``` + +## Example: Full-Stack Project + +```toml +# mise.toml for a project with Node.js frontend and Python backend + +[prepare.npm] +auto = true + +[prepare.poetry] +auto = true + +[prepare.prisma] +auto = true +sources = ["prisma/schema.prisma"] +outputs = ["node_modules/.prisma/"] +run = "npx prisma generate" + +[prepare.frontend-codegen] +sources = ["schema.graphql", "codegen.ts"] +outputs = ["src/generated/"] +run = "npm run codegen" +``` + +Running `mise prep` will check all four providers and run any that are stale, in parallel. diff --git a/e2e-win/prepare.Tests.ps1 b/e2e-win/prepare.Tests.ps1 new file mode 100644 index 0000000000..f80092ffb2 --- /dev/null +++ b/e2e-win/prepare.Tests.ps1 @@ -0,0 +1,33 @@ + +Describe 'prepare' { + BeforeAll { + $script:originalPath = Get-Location + # Set experimental since prepare requires it + $env:MISE_EXPERIMENTAL = "1" + } + + AfterAll { + Set-Location $script:originalPath + Remove-Item -Path Env:\MISE_TRUSTED_CONFIG_PATHS -ErrorAction SilentlyContinue + Remove-Item -Path Env:\MISE_EXPERIMENTAL -ErrorAction SilentlyContinue + } + + It 'lists no providers when no lockfiles exist' { + # Create unique test directory to avoid config inheritance from repo root + $testDir = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $testDir | Out-Null + Set-Location $testDir + $env:MISE_TRUSTED_CONFIG_PATHS = $testDir + + try { + mise prepare --list | Should -Match 'No prepare providers found' + } finally { + Set-Location $script:originalPath + Remove-Item -Path $testDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Note: Provider detection tests are skipped on Windows due to config discovery + # complexities. The prepare functionality is fully tested on Linux e2e tests. + # See e2e/cli/test_prepare for comprehensive coverage. +} diff --git a/e2e-win/task.Tests.ps1 b/e2e-win/task.Tests.ps1 index f3d19866d3..30c0e3c494 100644 --- a/e2e-win/task.Tests.ps1 +++ b/e2e-win/task.Tests.ps1 @@ -1,5 +1,42 @@ Describe 'task' { + BeforeAll { + $originalPath = Get-Location + Set-Location TestDrive: + # Trust the TestDrive config path - use $TestDrive for physical path, not PSDrive path + $env:MISE_TRUSTED_CONFIG_PATHS = $TestDrive + + # Create mise.toml that includes tasks directory + @' +[task_config] +includes = ["tasks"] +'@ | Out-File -FilePath "mise.toml" -Encoding utf8NoBOM + + # Create tasks directory + New-Item -ItemType Directory -Path "tasks" -Force | Out-Null + + # Create filetask.bat + @' +@echo off +echo mytask +'@ | Out-File -FilePath "tasks\filetask.bat" -Encoding ascii -NoNewline + + # Create filetask (no extension) for MISE_WINDOWS_DEFAULT_FILE_SHELL_ARGS test + @' +@echo off +echo mytask +'@ | Out-File -FilePath "tasks\filetask" -Encoding ascii -NoNewline + + # Create testtask.ps1 for pwsh test + @' +Write-Output "windows" +'@ | Out-File -FilePath "tasks\testtask.ps1" -Encoding utf8NoBOM + } + + AfterAll { + Set-Location $originalPath + Remove-Item -Path Env:\MISE_TRUSTED_CONFIG_PATHS -ErrorAction SilentlyContinue + } BeforeEach { Remove-Item -Path Env:\MISE_WINDOWS_EXECUTABLE_EXTENSIONS -ErrorAction SilentlyContinue diff --git a/e2e-win/vfox.Tests.ps1 b/e2e-win/vfox.Tests.ps1 index 42f5216f5e..745954f4db 100644 --- a/e2e-win/vfox.Tests.ps1 +++ b/e2e-win/vfox.Tests.ps1 @@ -1,4 +1,9 @@ Describe 'vfox' { + BeforeAll { + # vfox-npm is a custom backend which requires experimental mode + $env:MISE_EXPERIMENTAL = "1" + } + It 'executes vfox backend command execution' { # Test that vfox backend can execute commands cross-platform # This tests the cmd.exec function that was fixed for Windows compatibility diff --git a/e2e/cli/test_prepare b/e2e/cli/test_prepare new file mode 100644 index 0000000000..43d9decf26 --- /dev/null +++ b/e2e/cli/test_prepare @@ -0,0 +1,244 @@ +#!/usr/bin/env bash + +# Test mise prepare (mise prep) command + +# Test --list with no providers configured +assert_contains "mise prepare --list" "No prepare providers found" + +# Create a package-lock.json (npm provider needs to be configured to detect it) +cat >package-lock.json <<'EOF' +{ + "name": "test-project", + "lockfileVersion": 3, + "packages": {} +} +EOF + +# Still no providers without explicit config +assert_contains "mise prepare --list" "No prepare providers found" + +# Configure npm provider explicitly +cat >mise.toml <<'EOF' +[prepare.npm] +EOF + +# Now npm provider should be detected +assert_contains "mise prepare --list" "npm" +assert_contains "mise prepare --list" "package-lock.json" +assert_contains "mise prepare --list" "node_modules" + +# Test --dry-run shows what would run +assert_contains "mise prepare --dry-run" "npm" + +# Test alias works +assert_contains "mise prep --list" "npm" + +# Test with custom prepare provider (no .rules. prefix) +cat >mise.toml <<'EOF' +[prepare.npm] + +[prepare.codegen] +sources = ["schema.graphql"] +outputs = ["generated/"] +run = "echo codegen" +EOF + +# Create source file +touch schema.graphql + +assert_contains "mise prepare --list" "codegen" +assert_contains "mise prepare --list" "schema.graphql" + +# Test --only flag +assert_contains "mise prepare --dry-run --only codegen" "codegen" +assert_not_contains "mise prepare --dry-run --only codegen" "npm" + +# Test --skip flag +assert_not_contains "mise prepare --dry-run --skip npm" "npm install" + +# Test disable in config +cat >mise.toml <<'EOF' +[prepare] +disable = ["npm"] + +[prepare.npm] + +[prepare.codegen] +sources = ["schema.graphql"] +outputs = ["generated/"] +run = "echo codegen" +EOF + +assert_not_contains "mise prepare --list" "npm" +assert_contains "mise prepare --list" "codegen" + +# Test per-provider auto flag +cat >mise.toml <<'EOF' +[prepare.npm] +auto = true + +[prepare.codegen] +auto = false +sources = ["schema.graphql"] +outputs = ["generated/"] +run = "echo codegen" +EOF + +# Both should show in list +assert_contains "mise prepare --list" "npm" +assert_contains "mise prepare --list" "codegen" + +# Test yarn provider +rm -f package-lock.json +cat >yarn.lock <<'EOF' +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +EOF + +cat >mise.toml <<'EOF' +[prepare.yarn] +EOF + +assert_contains "mise prepare --list" "yarn" +assert_contains "mise prepare --list" "yarn.lock" +assert_contains "mise prepare --dry-run" "yarn" + +# Test pnpm provider +rm -f yarn.lock +cat >pnpm-lock.yaml <<'EOF' +lockfileVersion: '9.0' +EOF + +cat >mise.toml <<'EOF' +[prepare.pnpm] +EOF + +assert_contains "mise prepare --list" "pnpm" +assert_contains "mise prepare --list" "pnpm-lock.yaml" +assert_contains "mise prepare --dry-run" "pnpm" + +# Test bun provider (binary lockfile) +rm -f pnpm-lock.yaml +touch bun.lockb + +cat >mise.toml <<'EOF' +[prepare.bun] +EOF + +assert_contains "mise prepare --list" "bun" +assert_contains "mise prepare --list" "bun.lockb" +assert_contains "mise prepare --dry-run" "bun" + +# Test bun provider (text lockfile) +rm -f bun.lockb +cat >bun.lock <<'EOF' +# bun lockfile +EOF + +assert_contains "mise prepare --list" "bun" +assert_contains "mise prepare --list" "bun.lock" + +# Test go provider +rm -f bun.lock +cat >go.sum <<'EOF' +github.com/foo/bar v1.0.0 h1:abc +EOF +cat >go.mod <<'EOF' +module test +go 1.21 +EOF + +cat >mise.toml <<'EOF' +[prepare.go] +EOF + +assert_contains "mise prepare --list" "go" +assert_contains "mise prepare --list" "go.sum" +assert_contains "mise prepare --dry-run" "go" + +# Test pip provider +rm -f go.sum go.mod +cat >requirements.txt <<'EOF' +requests==2.31.0 +EOF + +cat >mise.toml <<'EOF' +[prepare.pip] +EOF + +assert_contains "mise prepare --list" "pip" +assert_contains "mise prepare --list" "requirements.txt" +assert_contains "mise prepare --dry-run" "pip" + +# Test poetry provider +rm -f requirements.txt +cat >poetry.lock <<'EOF' +[[package]] +name = "requests" +version = "2.31.0" +EOF +cat >pyproject.toml <<'EOF' +[tool.poetry] +name = "test" +EOF + +cat >mise.toml <<'EOF' +[prepare.poetry] +EOF + +assert_contains "mise prepare --list" "poetry" +assert_contains "mise prepare --list" "poetry.lock" +assert_contains "mise prepare --dry-run" "poetry" + +# Test uv provider +rm -f poetry.lock +cat >uv.lock <<'EOF' +version = 1 +EOF + +cat >mise.toml <<'EOF' +[prepare.uv] +EOF + +assert_contains "mise prepare --list" "uv" +assert_contains "mise prepare --list" "uv.lock" +assert_contains "mise prepare --dry-run" "uv" + +# Test bundler provider +rm -f uv.lock pyproject.toml +cat >Gemfile.lock <<'EOF' +GEM + specs: +EOF +cat >Gemfile <<'EOF' +source 'https://rubygems.org' +EOF + +cat >mise.toml <<'EOF' +[prepare.bundler] +EOF + +assert_contains "mise prepare --list" "bundler" +assert_contains "mise prepare --list" "Gemfile.lock" +assert_contains "mise prepare --dry-run" "bundler" + +# Test composer provider +rm -f Gemfile.lock Gemfile +cat >composer.lock <<'EOF' +{ + "_readme": ["This file locks the dependencies"] +} +EOF +cat >composer.json <<'EOF' +{} +EOF + +cat >mise.toml <<'EOF' +[prepare.composer] +EOF + +assert_contains "mise prepare --list" "composer" +assert_contains "mise prepare --list" "composer.lock" +assert_contains "mise prepare --dry-run" "composer" + +# Clean up +rm -f composer.lock composer.json package.json mise.toml schema.graphql diff --git a/man/man1/mise.1 b/man/man1/mise.1 index b3ccf490e6..ab9e11fd1b 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -338,6 +338,12 @@ Updates a plugin to the latest version \fIAliases: \fRup, upgrade .RE .TP +\fBprepare\fR +[experimental] Ensure project dependencies are ready +.RS +\fIAliases: \fRprep +.RE +.TP \fBprune\fR Delete unused versions of tools .TP @@ -885,6 +891,9 @@ Command string to execute Number of jobs to run in parallel [default: 4] .TP +\fB\-\-no\-prepare\fR +Skip automatic dependency preparation +.TP \fB\-\-raw\fR Directly pipe stdin/stdout/stderr from plugin to user Sets \-\-jobs=1 \fBArguments:\fR @@ -1586,6 +1595,36 @@ Default: 4 .TP \fB\fR Plugin(s) to update +.SH "MISE PREPARE" +[experimental] Ensure project dependencies are ready + +Runs all applicable prepare steps for the current project. +This checks if dependency lockfiles are newer than installed outputs +(e.g., package\-lock.json vs node_modules/) and runs install commands +if needed. + +Providers with `auto = true` are automatically invoked before `mise x` and `mise run` +unless skipped with the \-\-no\-prepare flag. +.PP +\fBUsage:\fR mise prepare [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-f, \-\-force\fR +Force run all prepare steps even if outputs are fresh +.TP +\fB\-\-list\fR +Show what prepare steps are available +.TP +\fB\-n, \-\-dry\-run\fR +Only check if prepare is needed, don't run commands +.TP +\fB\-\-only\fR \fI\fR +Run specific prepare rule(s) only +.TP +\fB\-\-skip\fR \fI\fR +Skip specific prepare rule(s) .SH "MISE PRUNE" Delete unused versions of tools @@ -1763,6 +1802,9 @@ Tool(s) to run in addition to what is in mise.toml files e.g.: node@20 python@3. \fB\-\-no\-cache\fR Do not use cache on remote tasks .TP +\fB\-\-no\-prepare\fR +Skip automatic dependency preparation +.TP \fB\-\-no\-timings\fR Hides elapsed time after each task completes @@ -2411,6 +2453,9 @@ Tool(s) to run in addition to what is in mise.toml files e.g.: node@20 python@3. \fB\-\-no\-cache\fR Do not use cache on remote tasks .TP +\fB\-\-no\-prepare\fR +Skip automatic dependency preparation +.TP \fB\-\-no\-timings\fR Hides elapsed time after each task completes diff --git a/mise.usage.kdl b/mise.usage.kdl index 57098cada8..b720fc53c9 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -275,6 +275,7 @@ cmd exec help="Execute a command with tool(s) set" { flag "-j --jobs" help="Number of jobs to run in parallel\n[default: 4]" { arg } + flag --no-prepare help="Skip automatic dependency preparation" flag --raw help="Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1" arg "[TOOL@VERSION]…" help="Tool(s) to start e.g.: node@20 python@3.10" required=#false var=#true arg "[-- COMMAND]…" help="Command string to execute (same as --command)" required=#false var=#true @@ -635,6 +636,20 @@ cmd plugins help="Manage plugins" { arg "[PLUGIN]…" help="Plugin(s) to update" required=#false var=#true } } +cmd prepare help="[experimental] Ensure project dependencies are ready" { + alias prep + long_help "[experimental] Ensure project dependencies are ready\n\nRuns all applicable prepare steps for the current project.\nThis checks if dependency lockfiles are newer than installed outputs\n(e.g., package-lock.json vs node_modules/) and runs install commands\nif needed.\n\nProviders with `auto = true` are automatically invoked before `mise x` and `mise run`\nunless skipped with the --no-prepare flag." + after_long_help "Examples:\n\n $ mise prepare # Run all applicable prepare steps\n $ mise prepare --dry-run # Show what would run without executing\n $ mise prepare --force # Force run even if outputs are fresh\n $ mise prepare --list # List available prepare providers\n $ mise prepare --only npm # Run only npm prepare\n $ mise prepare --skip npm # Skip npm prepare\n\nConfiguration:\n\n Configure prepare providers in mise.toml:\n\n ```toml\n # Built-in npm provider (auto-detects lockfile)\n [prepare.npm]\n auto = true # Auto-run before mise x/run\n\n # Custom provider\n [prepare.codegen]\n auto = true\n sources = [\"schema/*.graphql\"]\n outputs = [\"src/generated/\"]\n run = \"npm run codegen\"\n\n [prepare]\n disable = [\"npm\"] # Disable specific providers at runtime\n ```\n" + flag "-f --force" help="Force run all prepare steps even if outputs are fresh" + flag --list help="Show what prepare steps are available" + flag "-n --dry-run" help="Only check if prepare is needed, don't run commands" + flag --only help="Run specific prepare rule(s) only" var=#true { + arg + } + flag --skip help="Skip specific prepare rule(s)" var=#true { + arg + } +} cmd prune help="Delete unused versions of tools" { long_help "Delete unused versions of tools\n\nmise tracks which config files have been used in ~/.local/state/mise/tracked-configs\nVersions which are no longer the latest specified in any of those configs are deleted.\nVersions installed only with environment variables `MISE__VERSION` will be deleted,\nas will versions only referenced on the command line `mise exec @`.\n\nYou can list prunable tools with `mise ls --prunable`" after_long_help "Examples:\n\n $ mise prune --dry-run\n rm -rf ~/.local/share/mise/versions/node/20.0.0\n rm -rf ~/.local/share/mise/versions/node/20.0.1\n" @@ -692,6 +707,7 @@ cmd run help="Run task(s)" { arg } flag --no-cache help="Do not use cache on remote tasks" + flag --no-prepare help="Skip automatic dependency preparation" flag --no-timings help="Hides elapsed time after each task completes" { long_help "Hides elapsed time after each task completes\n\nDefault to always hide with `MISE_TASK_TIMINGS=0`" } @@ -972,6 +988,7 @@ cmd tasks help="Manage tasks" { arg } flag --no-cache help="Do not use cache on remote tasks" + flag --no-prepare help="Skip automatic dependency preparation" flag --no-timings help="Hides elapsed time after each task completes" { long_help "Hides elapsed time after each task completes\n\nDefault to always hide with `MISE_TASK_TIMINGS=0`" } diff --git a/schema/mise.json b/schema/mise.json index a0e0b57fa3..5dcd85535c 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -1158,6 +1158,11 @@ "description": "Show configured tools when entering a directory with a mise.toml file.", "type": "boolean" }, + "show_prepare_stale": { + "default": true, + "description": "Show warning when prepare providers have stale dependencies.", + "type": "boolean" + }, "truncate": { "default": true, "description": "Truncate status messages.", diff --git a/settings.toml b/settings.toml index 1cb595329d..aa7f1c4196 100644 --- a/settings.toml +++ b/settings.toml @@ -1362,6 +1362,12 @@ description = "Show configured tools when entering a directory with a mise.toml env = "MISE_STATUS_MESSAGE_SHOW_TOOLS" type = "Bool" +[status.show_prepare_stale] +default = true +description = "Show warning when prepare providers have stale dependencies." +env = "MISE_STATUS_SHOW_PREPARE_STALE" +type = "Bool" + [status.truncate] default = true description = "Truncate status messages." diff --git a/src/cli/en.rs b/src/cli/en.rs index d862d20acb..19e3d4a895 100644 --- a/src/cli/en.rs +++ b/src/cli/en.rs @@ -34,6 +34,7 @@ impl En { jobs: None, c: None, command: Some(command), + no_prepare: false, } .run() .await diff --git a/src/cli/exec.rs b/src/cli/exec.rs index b148199773..0c6efdbf10 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -13,6 +13,7 @@ use crate::cli::args::ToolArg; use crate::cmd; use crate::config::{Config, Settings}; use crate::env; +use crate::prepare::{PrepareEngine, PrepareOptions}; use crate::toolset::{InstallOptions, ResolveOptions, ToolsetBuilder}; /// Execute a command with tool(s) set @@ -45,6 +46,10 @@ pub struct Exec { #[clap(long, short, env = "MISE_JOBS", verbatim_doc_comment)] pub jobs: Option, + /// Skip automatic dependency preparation + #[clap(long)] + pub no_prepare: bool, + /// Directly pipe stdin/stdout/stderr from plugin to user /// Sets --jobs=1 #[clap(long, overrides_with = "jobs")] @@ -112,6 +117,18 @@ impl Exec { let (program, mut args) = parse_command(&env::SHELL, &self.command, &self.c); let mut env = measure!("env_with_path", { ts.env_with_path(&config).await? }); + // Run auto-enabled prepare steps (unless --no-prepare) + if !self.no_prepare { + let engine = PrepareEngine::new(config.clone())?; + engine + .run(PrepareOptions { + auto_only: true, // Only run providers with auto=true + env: env.clone(), + ..Default::default() + }) + .await?; + } + // Ensure MISE_ENV is set in the spawned shell if it was specified via -E flag if !env::MISE_ENV.is_empty() { env.insert("MISE_ENV".to_string(), env::MISE_ENV.join(",")); diff --git a/src/cli/hook_env.rs b/src/cli/hook_env.rs index 4b143dafe6..a5b62a308e 100644 --- a/src/cli/hook_env.rs +++ b/src/cli/hook_env.rs @@ -187,6 +187,7 @@ impl HookEnv { } } ts.notify_if_versions_missing(config).await; + crate::prepare::notify_if_stale(config); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e6b619f49c..ff757fd7bd 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -45,6 +45,7 @@ mod ls_remote; mod mcp; mod outdated; mod plugins; +mod prepare; mod prune; mod registry; #[cfg(debug_assertions)] @@ -221,6 +222,7 @@ pub enum Commands { Mcp(mcp::Mcp), Outdated(outdated::Outdated), Plugins(plugins::Plugins), + Prepare(prepare::Prepare), Prune(prune::Prune), Registry(registry::Registry), #[cfg(debug_assertions)] @@ -286,6 +288,7 @@ impl Commands { Self::Mcp(cmd) => cmd.run().await, Self::Outdated(cmd) => cmd.run().await, Self::Plugins(cmd) => cmd.run().await, + Self::Prepare(cmd) => cmd.run().await, Self::Prune(cmd) => cmd.run().await, Self::Registry(cmd) => cmd.run().await, #[cfg(debug_assertions)] @@ -647,6 +650,7 @@ impl Cli { no_cache: Default::default(), timeout: None, skip_deps: false, + no_prepare: false, }))); } else if let Some(cmd) = external::COMMANDS.get(&task) { external::execute( diff --git a/src/cli/prepare.rs b/src/cli/prepare.rs new file mode 100644 index 0000000000..c4355d6a51 --- /dev/null +++ b/src/cli/prepare.rs @@ -0,0 +1,161 @@ +use eyre::Result; + +use crate::config::Config; +use crate::miseprintln; +use crate::prepare::{PrepareEngine, PrepareOptions, PrepareStepResult}; +use crate::toolset::{InstallOptions, ToolsetBuilder}; + +/// [experimental] Ensure project dependencies are ready +/// +/// Runs all applicable prepare steps for the current project. +/// This checks if dependency lockfiles are newer than installed outputs +/// (e.g., package-lock.json vs node_modules/) and runs install commands +/// if needed. +/// +/// Providers with `auto = true` are automatically invoked before `mise x` and `mise run` +/// unless skipped with the --no-prepare flag. +#[derive(Debug, clap::Args)] +#[clap(visible_alias = "prep", verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] +pub struct Prepare { + /// Force run all prepare steps even if outputs are fresh + #[clap(long, short)] + pub force: bool, + + /// Show what prepare steps are available + #[clap(long)] + pub list: bool, + + /// Only check if prepare is needed, don't run commands + #[clap(long, short = 'n')] + pub dry_run: bool, + + /// Run specific prepare rule(s) only + #[clap(long)] + pub only: Option>, + + /// Skip specific prepare rule(s) + #[clap(long)] + pub skip: Option>, +} + +impl Prepare { + pub async fn run(self) -> Result<()> { + let mut config = Config::get().await?; + let engine = PrepareEngine::new(config.clone())?; + + if self.list { + self.list_providers(&engine)?; + return Ok(()); + } + + // Build and install toolset so tools like npm are available + let mut ts = ToolsetBuilder::new() + .with_default_to_latest(true) + .build(&config) + .await?; + + ts.install_missing_versions(&mut config, &InstallOptions::default()) + .await?; + + // Get toolset environment with PATH + let env = ts.env_with_path(&config).await?; + + let opts = PrepareOptions { + dry_run: self.dry_run, + force: self.force, + only: self.only, + skip: self.skip.unwrap_or_default(), + env, + ..Default::default() + }; + + let result = engine.run(opts).await?; + + // Report results + for step in &result.steps { + match step { + PrepareStepResult::Ran(id) => { + miseprintln!("Prepared: {}", id); + } + PrepareStepResult::WouldRun(id) => { + miseprintln!("[dry-run] Would prepare: {}", id); + } + PrepareStepResult::Fresh(id) => { + debug!("Fresh: {}", id); + } + PrepareStepResult::Skipped(id) => { + debug!("Skipped: {}", id); + } + } + } + + if !result.had_work() && !self.dry_run { + miseprintln!("All dependencies are up to date"); + } + + Ok(()) + } + + fn list_providers(&self, engine: &PrepareEngine) -> Result<()> { + let providers = engine.list_providers(); + + if providers.is_empty() { + miseprintln!("No prepare providers found for this project"); + return Ok(()); + } + + miseprintln!("Available prepare providers:"); + for provider in providers { + let sources = provider + .sources() + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + let outputs = provider + .outputs() + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + + miseprintln!(" {}", provider.id()); + miseprintln!(" sources: {}", sources); + miseprintln!(" outputs: {}", outputs); + } + + Ok(()) + } +} + +static AFTER_LONG_HELP: &str = color_print::cstr!( + r#"Examples: + + $ mise prepare # Run all applicable prepare steps + $ mise prepare --dry-run # Show what would run without executing + $ mise prepare --force # Force run even if outputs are fresh + $ mise prepare --list # List available prepare providers + $ mise prepare --only npm # Run only npm prepare + $ mise prepare --skip npm # Skip npm prepare + +Configuration: + + Configure prepare providers in mise.toml: + + ```toml + # Built-in npm provider (auto-detects lockfile) + [prepare.npm] + auto = true # Auto-run before mise x/run + + # Custom provider + [prepare.codegen] + auto = true + sources = ["schema/*.graphql"] + outputs = ["src/generated/"] + run = "npm run codegen" + + [prepare] + disable = ["npm"] # Disable specific providers at runtime + ``` +"# +); diff --git a/src/cli/run.rs b/src/cli/run.rs index 25918c3be7..8cd8889b6a 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -8,12 +8,14 @@ use super::args::ToolArg; use crate::cli::{Cli, unescape_task_args}; use crate::config::{Config, Settings}; use crate::duration; +use crate::prepare::{PrepareEngine, PrepareOptions}; use crate::task::has_any_args_defined; use crate::task::task_helpers::task_needs_permit; use crate::task::task_list::{get_task_lists, resolve_depends}; use crate::task::task_output::TaskOutput; use crate::task::task_output_handler::OutputHandler; use crate::task::{Deps, Task}; +use crate::toolset::{InstallOptions, ToolsetBuilder}; use crate::ui::{ctrlc, style}; use clap::{CommandFactory, ValueHint}; use eyre::{Result, bail, eyre}; @@ -158,6 +160,10 @@ pub struct Run { #[clap(long, verbatim_doc_comment, env = "MISE_TASK_REMOTE_NO_CACHE")] pub no_cache: bool, + /// Skip automatic dependency preparation + #[clap(long)] + pub no_prepare: bool, + /// Hides elapsed time after each task completes /// /// Default to always hide with `MISE_TASK_TIMINGS=0` @@ -194,7 +200,7 @@ pub struct Run { impl Run { pub async fn run(mut self) -> Result<()> { - let config = Config::get().await?; + // Check help flags before doing any work if self.task == "-h" { self.get_clap_command().print_help()?; return Ok(()); @@ -204,19 +210,18 @@ impl Run { return Ok(()); } - // Unescape task args that were escaped to prevent clap from parsing them + // Unescape task args early so we can check for help flags self.args = unescape_task_args(&self.args); - if !self.skip_deps { - self.skip_deps = Settings::get().task_skip_depends; - } - - // Check if --help or -h is in the task args + // Check if --help or -h is in the task args BEFORE toolset/prepare // NOTE: Only check self.args, not self.args_last, because args_last contains // arguments after explicit -- which should always be passed through to the task let has_help_in_task_args = self.args.contains(&"--help".to_string()) || self.args.contains(&"-h".to_string()); + let mut config = Config::get().await?; + + // Handle task help early to avoid unnecessary toolset/prepare work if has_help_in_task_args { // Build args list to get the task (filter out --help/-h for task lookup) let args = once(self.task.clone()) @@ -249,6 +254,37 @@ impl Run { } } + // Build and install toolset so tools like npm are available for prepare + let mut ts = ToolsetBuilder::new() + .with_args(&self.tool) + .with_default_to_latest(true) + .build(&config) + .await?; + + let opts = InstallOptions { + jobs: self.jobs, + raw: self.raw, + ..Default::default() + }; + ts.install_missing_versions(&mut config, &opts).await?; + + // Run auto-enabled prepare steps (unless --no-prepare) + if !self.no_prepare { + let env = ts.env_with_path(&config).await?; + let engine = PrepareEngine::new(config.clone())?; + engine + .run(PrepareOptions { + auto_only: true, // Only run providers with auto=true + env, + ..Default::default() + }) + .await?; + } + + if !self.skip_deps { + self.skip_deps = Settings::get().task_skip_depends; + } + time!("run init"); let tmpdir = tempfile::tempdir()?; self.tmpdir = tmpdir.path().to_path_buf(); diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index d266d6131d..cf67b8bf9e 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -25,6 +25,7 @@ use crate::config::{Alias, AliasMap, Config}; use crate::env_diff::EnvMap; use crate::file::{create_dir_all, display_path}; use crate::hooks::{Hook, Hooks}; +use crate::prepare::PrepareConfig; use crate::redactions::Redactions; use crate::registry::REGISTRY; use crate::task::Task; @@ -74,6 +75,8 @@ pub struct MiseToml { #[serde(default)] watch_files: Vec, #[serde(default)] + prepare: Option, + #[serde(default)] vars: EnvList, #[serde(default)] settings: SettingsPartial, @@ -717,6 +720,10 @@ impl ConfigFile for MiseToml { .flatten() .collect()) } + + fn prepare_config(&self) -> Option { + self.prepare.clone() + } } /// Returns a [`toml_edit::Key`] from the given `key`. @@ -785,6 +792,7 @@ impl Clone for MiseToml { task_config: self.task_config.clone(), settings: self.settings.clone(), watch_files: self.watch_files.clone(), + prepare: self.prepare.clone(), vars: self.vars.clone(), experimental_monorepo_root: self.experimental_monorepo_root, } diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index c4def7f6eb..7d4291cb47 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -17,6 +17,7 @@ use crate::errors::Error::UntrustedConfig; use crate::file::display_path; use crate::hash::hash_to_str; use crate::hooks::Hook; +use crate::prepare::PrepareConfig; use crate::redactions::Redactions; use crate::task::Task; use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource, ToolVersionList, Toolset}; @@ -124,6 +125,10 @@ pub trait ConfigFile: Debug + Send + Sync { fn hooks(&self) -> Result> { Ok(Default::default()) } + + fn prepare_config(&self) -> Option { + None + } } impl dyn ConfigFile { diff --git a/src/main.rs b/src/main.rs index fee8e9fd9e..bac9b19c28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,6 +67,7 @@ mod path; mod path_env; mod platform; mod plugins; +mod prepare; mod rand; mod redactions; mod registry; diff --git a/src/prepare/engine.rs b/src/prepare/engine.rs new file mode 100644 index 0000000000..c3cda61e94 --- /dev/null +++ b/src/prepare/engine.rs @@ -0,0 +1,389 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use eyre::Result; + +use crate::cmd::CmdLineRunner; +use crate::config::{Config, Settings}; +use crate::parallel; +use crate::ui::multi_progress_report::MultiProgressReport; + +use super::PrepareProvider; +use super::providers::{ + BunPrepareProvider, BundlerPrepareProvider, ComposerPrepareProvider, CustomPrepareProvider, + GoPrepareProvider, NpmPrepareProvider, PipPrepareProvider, PnpmPrepareProvider, + PoetryPrepareProvider, UvPrepareProvider, YarnPrepareProvider, +}; +use super::rule::{BUILTIN_PROVIDERS, PrepareConfig}; + +/// Options for running prepare steps +#[derive(Debug, Default)] +pub struct PrepareOptions { + /// Only check if prepare is needed, don't run commands + pub dry_run: bool, + /// Force run all prepare steps even if outputs are fresh + pub force: bool, + /// Run specific prepare rule(s) only + pub only: Option>, + /// Skip specific prepare rule(s) + pub skip: Vec, + /// Environment variables to pass to prepare commands (e.g., toolset PATH) + pub env: BTreeMap, + /// If true, only run providers with auto=true + pub auto_only: bool, +} + +/// Result of a prepare step +#[derive(Debug)] +pub enum PrepareStepResult { + /// Step ran successfully + Ran(String), + /// Step would have run (dry-run mode) + WouldRun(String), + /// Step was skipped because outputs are fresh + Fresh(String), + /// Step was skipped by user request + Skipped(String), +} + +/// Result of running all prepare steps +#[derive(Debug)] +pub struct PrepareResult { + pub steps: Vec, +} + +impl PrepareResult { + /// Returns true if any steps ran or would have run + pub fn had_work(&self) -> bool { + self.steps.iter().any(|s| { + matches!( + s, + PrepareStepResult::Ran(_) | PrepareStepResult::WouldRun(_) + ) + }) + } +} + +/// Engine that discovers and runs prepare providers +pub struct PrepareEngine { + config: Arc, + providers: Vec>, +} + +impl PrepareEngine { + /// Create a new PrepareEngine, discovering all applicable providers + pub fn new(config: Arc) -> Result { + let providers = Self::discover_providers(&config)?; + // Only require experimental when prepare is actually configured + if !providers.is_empty() { + Settings::get().ensure_experimental("prepare")?; + } + Ok(Self { config, providers }) + } + + /// Discover all applicable prepare providers for the current project + fn discover_providers(config: &Config) -> Result>> { + let project_root = config + .project_root + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + + let mut providers: Vec> = vec![]; + + // Load prepare config from mise.toml + let prepare_config = config + .config_files + .values() + .filter_map(|cf| cf.prepare_config()) + .fold(PrepareConfig::default(), |acc, pc| acc.merge(&pc)); + + // Iterate over all configured providers + for (id, provider_config) in &prepare_config.providers { + let provider: Box = if BUILTIN_PROVIDERS.contains(&id.as_str()) { + // Built-in provider with specialized implementation + match id.as_str() { + // Node.js package managers + "npm" => Box::new(NpmPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + "yarn" => Box::new(YarnPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + "pnpm" => Box::new(PnpmPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + "bun" => Box::new(BunPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + // Go + "go" => Box::new(GoPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + // Python + "pip" => Box::new(PipPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + "poetry" => Box::new(PoetryPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + "uv" => Box::new(UvPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + // Ruby + "bundler" => Box::new(BundlerPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + // PHP + "composer" => Box::new(ComposerPrepareProvider::new( + &project_root, + provider_config.clone(), + )), + _ => continue, // Skip unimplemented built-ins + } + } else { + // Custom provider + Box::new(CustomPrepareProvider::new( + id.clone(), + provider_config.clone(), + project_root.clone(), + )) + }; + + if provider.is_applicable() { + providers.push(provider); + } + } + + // Filter disabled providers + providers.retain(|p| !prepare_config.disable.contains(&p.id().to_string())); + + Ok(providers) + } + + /// List all discovered providers + pub fn list_providers(&self) -> Vec<&dyn PrepareProvider> { + self.providers.iter().map(|p| p.as_ref()).collect() + } + + /// Check if any auto-enabled provider has stale outputs (without running) + /// Returns the IDs of stale providers + pub fn check_staleness(&self) -> Vec<&str> { + self.providers + .iter() + .filter(|p| p.is_auto()) + .filter(|p| !self.check_freshness(p.as_ref()).unwrap_or(true)) + .map(|p| p.id()) + .collect() + } + + /// Run all stale prepare steps in parallel + pub async fn run(&self, opts: PrepareOptions) -> Result { + let mut results = vec![]; + + // Collect providers that need to run with their commands + let mut to_run: Vec<(String, super::PrepareCommand)> = vec![]; + + for provider in &self.providers { + let id = provider.id().to_string(); + + // Check auto_only filter + if opts.auto_only && !provider.is_auto() { + trace!("prepare step {} is not auto, skipping", id); + results.push(PrepareStepResult::Skipped(id)); + continue; + } + + // Check skip list + if opts.skip.contains(&id) { + results.push(PrepareStepResult::Skipped(id)); + continue; + } + + // Check only list + if let Some(ref only) = opts.only + && !only.contains(&id) + { + results.push(PrepareStepResult::Skipped(id)); + continue; + } + + let is_fresh = if opts.force { + false + } else { + self.check_freshness(provider.as_ref())? + }; + + if !is_fresh { + let cmd = provider.prepare_command()?; + + if opts.dry_run { + // Just record that it would run, let CLI handle output + results.push(PrepareStepResult::WouldRun(id)); + } else { + to_run.push((id, cmd)); + } + } else { + trace!("prepare step {} is fresh, skipping", id); + results.push(PrepareStepResult::Fresh(id)); + } + } + + // Run stale providers in parallel + if !to_run.is_empty() { + let mpr = MultiProgressReport::get(); + let project_root = self + .config + .project_root + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let toolset_env = opts.env.clone(); + + // Include all data in the tuple so closure doesn't capture anything + let to_run_with_context: Vec<_> = to_run + .into_iter() + .map(|(id, cmd)| { + ( + id, + cmd, + mpr.clone(), + project_root.clone(), + toolset_env.clone(), + ) + }) + .collect(); + + let run_results = parallel::parallel( + to_run_with_context, + |(id, cmd, mpr, project_root, toolset_env)| async move { + let pr = mpr.add(&cmd.description); + match Self::execute_prepare_static(&cmd, &toolset_env, &project_root) { + Ok(()) => { + pr.finish_with_message(format!("{} done", cmd.description)); + Ok(PrepareStepResult::Ran(id)) + } + Err(e) => { + pr.finish_with_message(format!("{} failed: {}", cmd.description, e)); + Err(e) + } + } + }, + ) + .await?; + + results.extend(run_results); + } + + Ok(PrepareResult { steps: results }) + } + + /// Check if outputs are newer than sources (stateless mtime comparison) + fn check_freshness(&self, provider: &dyn PrepareProvider) -> Result { + let sources = provider.sources(); + let outputs = provider.outputs(); + + if outputs.is_empty() { + return Ok(false); // No outputs defined, always run to be safe + } + // Note: empty sources is handled below - last_modified([]) returns None, + // and if outputs don't exist either, (_, None) takes precedence → stale + + let sources_mtime = Self::last_modified(&sources)?; + let outputs_mtime = Self::last_modified(&outputs)?; + + match (sources_mtime, outputs_mtime) { + (Some(src), Some(out)) => Ok(src <= out), // Fresh if outputs newer or equal to sources + (_, None) => Ok(false), // No outputs exist, not fresh (takes precedence) + (None, _) => Ok(true), // No sources exist, consider fresh + } + } + + /// Get the most recent modification time from a list of paths + /// For directories, recursively finds the newest file within (up to 3 levels deep) + fn last_modified(paths: &[PathBuf]) -> Result> { + let mut mtimes: Vec = vec![]; + + for path in paths.iter().filter(|p| p.exists()) { + if path.is_dir() { + // For directories, find the newest file within (limited depth for performance) + if let Some(mtime) = Self::newest_file_in_dir(path, 3) { + mtimes.push(mtime); + } + } else if let Some(mtime) = path.metadata().ok().and_then(|m| m.modified().ok()) { + mtimes.push(mtime); + } + } + + Ok(mtimes.into_iter().max()) + } + + /// Recursively find the newest file modification time in a directory + fn newest_file_in_dir(dir: &Path, max_depth: usize) -> Option { + if max_depth == 0 { + return dir.metadata().ok().and_then(|m| m.modified().ok()); + } + + let mut newest: Option = None; + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let mtime = if path.is_dir() { + Self::newest_file_in_dir(&path, max_depth - 1) + } else { + path.metadata().ok().and_then(|m| m.modified().ok()) + }; + + if let Some(t) = mtime { + newest = Some(newest.map_or(t, |n| n.max(t))); + } + } + } + + newest + } + + /// Execute a prepare command (static version for parallel execution) + fn execute_prepare_static( + cmd: &super::PrepareCommand, + toolset_env: &BTreeMap, + default_project_root: &Path, + ) -> Result<()> { + let cwd = cmd + .cwd + .clone() + .unwrap_or_else(|| default_project_root.to_path_buf()); + + let mut runner = CmdLineRunner::new(&cmd.program) + .args(&cmd.args) + .current_dir(cwd); + + // Apply toolset environment (includes PATH with installed tools) + for (k, v) in toolset_env { + runner = runner.env(k, v); + } + + // Apply command-specific environment (can override toolset env) + for (k, v) in &cmd.env { + runner = runner.env(k, v); + } + + // Use raw output for better UX during dependency installation + if Settings::get().raw { + runner = runner.raw(true); + } + + runner.execute()?; + Ok(()) + } +} diff --git a/src/prepare/mod.rs b/src/prepare/mod.rs new file mode 100644 index 0000000000..4cd93dc986 --- /dev/null +++ b/src/prepare/mod.rs @@ -0,0 +1,111 @@ +use std::collections::BTreeMap; +use std::fmt::Debug; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use eyre::{Result, bail}; + +use crate::config::{Config, Settings}; +use crate::env; + +pub use engine::{PrepareEngine, PrepareOptions, PrepareStepResult}; +pub use rule::PrepareConfig; + +mod engine; +pub mod providers; +mod rule; + +/// A command to execute for preparation +#[derive(Debug, Clone)] +pub struct PrepareCommand { + /// The program to execute + pub program: String, + /// Arguments to pass to the program + pub args: Vec, + /// Environment variables to set + pub env: BTreeMap, + /// Working directory (defaults to project root) + pub cwd: Option, + /// Human-readable description of what this command does + pub description: String, +} + +impl PrepareCommand { + /// Create a PrepareCommand from a run string like "npm install" + /// + /// Uses shell-aware parsing to handle quoted arguments correctly. + pub fn from_string( + run: &str, + project_root: &Path, + config: &rule::PrepareProviderConfig, + ) -> Result { + let parts = shell_words::split(run).map_err(|e| eyre::eyre!("invalid command: {e}"))?; + + if parts.is_empty() { + bail!("prepare run command cannot be empty"); + } + + let (program, args) = parts.split_first().unwrap(); + + Ok(Self { + program: program.to_string(), + args: args.to_vec(), + env: config.env.clone(), + cwd: config + .dir + .as_ref() + .map(|d| project_root.join(d)) + .or_else(|| Some(project_root.to_path_buf())), + description: config + .description + .clone() + .unwrap_or_else(|| run.to_string()), + }) + } +} + +/// Trait for prepare providers that can check and install dependencies +pub trait PrepareProvider: Debug + Send + Sync { + /// Unique identifier for this provider (e.g., "npm", "cargo", "codegen") + fn id(&self) -> &str; + + /// Returns the source files to check for freshness (lock files, config files) + fn sources(&self) -> Vec; + + /// Returns the output files/directories that should be newer than sources + fn outputs(&self) -> Vec; + + /// The command to run when outputs are stale relative to sources + fn prepare_command(&self) -> Result; + + /// Whether this provider is applicable (e.g., lockfile exists) + fn is_applicable(&self) -> bool; + + /// Whether this provider should auto-run before mise x/run + fn is_auto(&self) -> bool { + false + } +} + +/// Warn if any auto-enabled prepare providers are stale +pub fn notify_if_stale(config: &Arc) { + // Skip in shims or quiet mode + if *env::__MISE_SHIM || Settings::get().quiet { + return; + } + + // Check if this feature is enabled + if !Settings::get().status.show_prepare_stale { + return; + } + + let Ok(engine) = PrepareEngine::new(config.clone()) else { + return; + }; + + let stale = engine.check_staleness(); + if !stale.is_empty() { + let providers = stale.join(", "); + warn!("prepare: {providers} may need update, run `mise prep`"); + } +} diff --git a/src/prepare/providers/bun.rs b/src/prepare/providers/bun.rs new file mode 100644 index 0000000000..83d009d59c --- /dev/null +++ b/src/prepare/providers/bun.rs @@ -0,0 +1,80 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for bun (bun.lockb or bun.lock) +#[derive(Debug)] +pub struct BunPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl BunPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } + + fn lockfile_path(&self) -> Option { + // Bun supports both bun.lockb (binary) and bun.lock (text) + let binary_lock = self.project_root.join("bun.lockb"); + if binary_lock.exists() { + return Some(binary_lock); + } + let text_lock = self.project_root.join("bun.lock"); + if text_lock.exists() { + return Some(text_lock); + } + None + } +} + +impl PrepareProvider for BunPrepareProvider { + fn id(&self) -> &str { + "bun" + } + + fn sources(&self) -> Vec { + let mut sources = vec![]; + if let Some(lockfile) = self.lockfile_path() { + sources.push(lockfile); + } + sources.push(self.project_root.join("package.json")); + sources + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join("node_modules")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "bun".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "bun install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.lockfile_path().is_some() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/bundler.rs b/src/prepare/providers/bundler.rs new file mode 100644 index 0000000000..6f88fa0ab6 --- /dev/null +++ b/src/prepare/providers/bundler.rs @@ -0,0 +1,72 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for Ruby Bundler (Gemfile.lock) +#[derive(Debug)] +pub struct BundlerPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl BundlerPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for BundlerPrepareProvider { + fn id(&self) -> &str { + "bundler" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("Gemfile.lock"), + self.project_root.join("Gemfile"), + ] + } + + fn outputs(&self) -> Vec { + // Check for vendor/bundle if using --path vendor/bundle + let vendor = self.project_root.join("vendor/bundle"); + if vendor.exists() { + vec![vendor] + } else { + // Use .bundle directory as fallback indicator + vec![self.project_root.join(".bundle")] + } + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "bundle".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "bundle install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("Gemfile.lock").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/composer.rs b/src/prepare/providers/composer.rs new file mode 100644 index 0000000000..9f40b3c122 --- /dev/null +++ b/src/prepare/providers/composer.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for PHP Composer (composer.lock) +#[derive(Debug)] +pub struct ComposerPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl ComposerPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for ComposerPrepareProvider { + fn id(&self) -> &str { + "composer" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("composer.lock"), + self.project_root.join("composer.json"), + ] + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join("vendor")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "composer".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "composer install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("composer.lock").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/custom.rs b/src/prepare/providers/custom.rs new file mode 100644 index 0000000000..c3b6646cc8 --- /dev/null +++ b/src/prepare/providers/custom.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; + +use eyre::Result; +use glob::glob; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for user-defined custom rules from mise.toml [prepare.*] +#[derive(Debug)] +pub struct CustomPrepareProvider { + id: String, + config: PrepareProviderConfig, + project_root: PathBuf, +} + +impl CustomPrepareProvider { + pub fn new(id: String, config: PrepareProviderConfig, project_root: PathBuf) -> Self { + Self { + id, + config, + project_root, + } + } + + /// Expand glob patterns in sources/outputs + fn expand_globs(&self, patterns: &[String]) -> Vec { + let mut paths = vec![]; + + for pattern in patterns { + let full_pattern = if PathBuf::from(pattern).is_relative() { + self.project_root.join(pattern) + } else { + PathBuf::from(pattern) + }; + + // Check if it's a glob pattern + if pattern.contains('*') || pattern.contains('{') || pattern.contains('?') { + if let Ok(entries) = glob(full_pattern.to_string_lossy().as_ref()) { + for entry in entries.flatten() { + paths.push(entry); + } + } + } else if full_pattern.exists() { + paths.push(full_pattern); + } else { + // Include even if doesn't exist (for outputs that may not exist yet) + paths.push(full_pattern); + } + } + + paths + } +} + +impl PrepareProvider for CustomPrepareProvider { + fn id(&self) -> &str { + &self.id + } + + fn sources(&self) -> Vec { + self.expand_globs(&self.config.sources) + } + + fn outputs(&self) -> Vec { + self.expand_globs(&self.config.outputs) + } + + fn prepare_command(&self) -> Result { + let run = self + .config + .run + .as_ref() + .ok_or_else(|| eyre::eyre!("prepare rule {} has no run command", self.id))?; + + PrepareCommand::from_string(run, &self.project_root, &self.config) + } + + fn is_applicable(&self) -> bool { + // Custom providers require a run command to be applicable + self.config.run.is_some() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/go.rs b/src/prepare/providers/go.rs new file mode 100644 index 0000000000..8d7578533a --- /dev/null +++ b/src/prepare/providers/go.rs @@ -0,0 +1,85 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for Go (go.sum) +#[derive(Debug)] +pub struct GoPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl GoPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for GoPrepareProvider { + fn id(&self) -> &str { + "go" + } + + fn sources(&self) -> Vec { + // go.mod defines dependencies - changes here trigger downloads + vec![self.project_root.join("go.mod")] + } + + fn outputs(&self) -> Vec { + // Go downloads modules to GOPATH/pkg/mod, but we can check vendor/ if used + let vendor = self.project_root.join("vendor"); + if vendor.exists() { + vec![vendor] + } else { + // go.sum gets updated after go mod download completes + vec![self.project_root.join("go.sum")] + } + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + // Use `go mod vendor` if vendor/ exists, otherwise `go mod download` + let vendor = self.project_root.join("vendor"); + let (args, desc) = if vendor.exists() { + ( + vec!["mod".to_string(), "vendor".to_string()], + "go mod vendor", + ) + } else { + ( + vec!["mod".to_string(), "download".to_string()], + "go mod download", + ) + }; + + Ok(PrepareCommand { + program: "go".to_string(), + args, + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| desc.to_string()), + }) + } + + fn is_applicable(&self) -> bool { + // Check for go.mod (the source/lockfile), not go.sum (which may be an output) + self.project_root.join("go.mod").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/mod.rs b/src/prepare/providers/mod.rs new file mode 100644 index 0000000000..50f08a0af3 --- /dev/null +++ b/src/prepare/providers/mod.rs @@ -0,0 +1,23 @@ +mod bun; +mod bundler; +mod composer; +mod custom; +mod go; +mod npm; +mod pip; +mod pnpm; +mod poetry; +mod uv; +mod yarn; + +pub use bun::BunPrepareProvider; +pub use bundler::BundlerPrepareProvider; +pub use composer::ComposerPrepareProvider; +pub use custom::CustomPrepareProvider; +pub use go::GoPrepareProvider; +pub use npm::NpmPrepareProvider; +pub use pip::PipPrepareProvider; +pub use pnpm::PnpmPrepareProvider; +pub use poetry::PoetryPrepareProvider; +pub use uv::UvPrepareProvider; +pub use yarn::YarnPrepareProvider; diff --git a/src/prepare/providers/npm.rs b/src/prepare/providers/npm.rs new file mode 100644 index 0000000000..acfba998b9 --- /dev/null +++ b/src/prepare/providers/npm.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for npm (package-lock.json) +#[derive(Debug)] +pub struct NpmPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl NpmPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for NpmPrepareProvider { + fn id(&self) -> &str { + "npm" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("package-lock.json"), + self.project_root.join("package.json"), + ] + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join("node_modules")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "npm".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "npm install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("package-lock.json").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/pip.rs b/src/prepare/providers/pip.rs new file mode 100644 index 0000000000..a0e5a1c1e9 --- /dev/null +++ b/src/prepare/providers/pip.rs @@ -0,0 +1,67 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for pip (requirements.txt) +#[derive(Debug)] +pub struct PipPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl PipPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for PipPrepareProvider { + fn id(&self) -> &str { + "pip" + } + + fn sources(&self) -> Vec { + vec![self.project_root.join("requirements.txt")] + } + + fn outputs(&self) -> Vec { + // Check for .venv directory as output indicator + vec![self.project_root.join(".venv")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "pip".to_string(), + args: vec![ + "install".to_string(), + "-r".to_string(), + "requirements.txt".to_string(), + ], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "pip install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("requirements.txt").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/pnpm.rs b/src/prepare/providers/pnpm.rs new file mode 100644 index 0000000000..e7992fbd63 --- /dev/null +++ b/src/prepare/providers/pnpm.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for pnpm (pnpm-lock.yaml) +#[derive(Debug)] +pub struct PnpmPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl PnpmPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for PnpmPrepareProvider { + fn id(&self) -> &str { + "pnpm" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("pnpm-lock.yaml"), + self.project_root.join("package.json"), + ] + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join("node_modules")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "pnpm".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "pnpm install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("pnpm-lock.yaml").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/poetry.rs b/src/prepare/providers/poetry.rs new file mode 100644 index 0000000000..be6f560d7b --- /dev/null +++ b/src/prepare/providers/poetry.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for Poetry (poetry.lock) +#[derive(Debug)] +pub struct PoetryPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl PoetryPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for PoetryPrepareProvider { + fn id(&self) -> &str { + "poetry" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("poetry.lock"), + self.project_root.join("pyproject.toml"), + ] + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join(".venv")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "poetry".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "poetry install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("poetry.lock").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/uv.rs b/src/prepare/providers/uv.rs new file mode 100644 index 0000000000..b83640e05e --- /dev/null +++ b/src/prepare/providers/uv.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for uv (uv.lock) +#[derive(Debug)] +pub struct UvPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl UvPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for UvPrepareProvider { + fn id(&self) -> &str { + "uv" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("uv.lock"), + self.project_root.join("pyproject.toml"), + ] + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join(".venv")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "uv".to_string(), + args: vec!["sync".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "uv sync".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("uv.lock").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/providers/yarn.rs b/src/prepare/providers/yarn.rs new file mode 100644 index 0000000000..2fe378c967 --- /dev/null +++ b/src/prepare/providers/yarn.rs @@ -0,0 +1,65 @@ +use std::path::{Path, PathBuf}; + +use eyre::Result; + +use crate::prepare::rule::PrepareProviderConfig; +use crate::prepare::{PrepareCommand, PrepareProvider}; + +/// Prepare provider for yarn (yarn.lock) +#[derive(Debug)] +pub struct YarnPrepareProvider { + project_root: PathBuf, + config: PrepareProviderConfig, +} + +impl YarnPrepareProvider { + pub fn new(project_root: &Path, config: PrepareProviderConfig) -> Self { + Self { + project_root: project_root.to_path_buf(), + config, + } + } +} + +impl PrepareProvider for YarnPrepareProvider { + fn id(&self) -> &str { + "yarn" + } + + fn sources(&self) -> Vec { + vec![ + self.project_root.join("yarn.lock"), + self.project_root.join("package.json"), + ] + } + + fn outputs(&self) -> Vec { + vec![self.project_root.join("node_modules")] + } + + fn prepare_command(&self) -> Result { + if let Some(run) = &self.config.run { + return PrepareCommand::from_string(run, &self.project_root, &self.config); + } + + Ok(PrepareCommand { + program: "yarn".to_string(), + args: vec!["install".to_string()], + env: self.config.env.clone(), + cwd: Some(self.project_root.clone()), + description: self + .config + .description + .clone() + .unwrap_or_else(|| "yarn install".to_string()), + }) + } + + fn is_applicable(&self) -> bool { + self.project_root.join("yarn.lock").exists() + } + + fn is_auto(&self) -> bool { + self.config.auto + } +} diff --git a/src/prepare/rule.rs b/src/prepare/rule.rs new file mode 100644 index 0000000000..15b4a2bf73 --- /dev/null +++ b/src/prepare/rule.rs @@ -0,0 +1,83 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// List of built-in provider names that have specialized implementations +pub const BUILTIN_PROVIDERS: &[&str] = &[ + "npm", "yarn", "pnpm", "bun", // Node.js + "go", // Go + "pip", // Python (requirements.txt) + "poetry", // Python (poetry) + "uv", // Python (uv) + "bundler", // Ruby + "composer", // PHP +]; + +/// Configuration for a prepare provider (both built-in and custom) +/// +/// Built-in providers have auto-detected sources/outputs and default run commands. +/// Custom providers require explicit sources, outputs, and run. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PrepareProviderConfig { + /// Whether to auto-run this provider before mise x/run (default: false) + #[serde(default)] + pub auto: bool, + /// Command to run when stale (required for custom, optional override for built-in) + pub run: Option, + /// Files/patterns to check for changes (required for custom, auto-detected for built-in) + #[serde(default)] + pub sources: Vec, + /// Files/directories that should be newer than sources (required for custom, auto-detected for built-in) + #[serde(default)] + pub outputs: Vec, + /// Environment variables to set + #[serde(default)] + pub env: BTreeMap, + /// Working directory + pub dir: Option, + /// Optional description + pub description: Option, +} + +impl PrepareProviderConfig { + /// Check if this is a custom rule (has explicit run command and is not a built-in name) + pub fn is_custom(&self, name: &str) -> bool { + !BUILTIN_PROVIDERS.contains(&name) && self.run.is_some() + } +} + +/// Top-level [prepare] configuration section +/// +/// All providers are configured at the same level: +/// - `[prepare.npm]` - built-in npm provider +/// - `[prepare.codegen]` - custom provider +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct PrepareConfig { + /// List of provider IDs to disable at runtime + #[serde(default)] + pub disable: Vec, + /// All provider configurations (both built-in and custom) + #[serde(flatten)] + pub providers: BTreeMap, +} + +impl PrepareConfig { + /// Merge two PrepareConfigs, with `other` taking precedence + pub fn merge(&self, other: &PrepareConfig) -> PrepareConfig { + let mut providers = self.providers.clone(); + for (k, v) in &other.providers { + providers.insert(k.clone(), v.clone()); + } + + let mut disable = self.disable.clone(); + disable.extend(other.disable.clone()); + + PrepareConfig { disable, providers } + } + + /// Get a provider config by name + pub fn get(&self, name: &str) -> Option<&PrepareProviderConfig> { + self.providers.get(name) + } +} diff --git a/src/shims.rs b/src/shims.rs index 35227d79cb..5cdd828a8e 100644 --- a/src/shims.rs +++ b/src/shims.rs @@ -43,6 +43,7 @@ pub async fn handle_shim() -> Result<()> { command: Some(args), jobs: None, raw: false, + no_prepare: true, // Skip prepare for shims to avoid performance impact }; time!("shim exec"); exec.run().await?; diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index f4012e08ea..23b5018154 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -978,6 +978,11 @@ const completionSpec: Fig.Spec = { name: "jobs", }, }, + { + name: "--no-prepare", + description: "Skip automatic dependency preparation", + isRepeatable: false, + }, { name: "--raw", description: @@ -1830,6 +1835,43 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ["prepare", "prep"], + description: "[experimental] Ensure project dependencies are ready", + options: [ + { + name: ["-f", "--force"], + description: "Force run all prepare steps even if outputs are fresh", + isRepeatable: false, + }, + { + name: "--list", + description: "Show what prepare steps are available", + isRepeatable: false, + }, + { + name: ["-n", "--dry-run"], + description: "Only check if prepare is needed, don't run commands", + isRepeatable: false, + }, + { + name: "--only", + description: "Run specific prepare rule(s) only", + isRepeatable: true, + args: { + name: "only", + }, + }, + { + name: "--skip", + description: "Skip specific prepare rule(s)", + isRepeatable: true, + args: { + name: "skip", + }, + }, + ], + }, { name: "prune", description: "Delete unused versions of tools", @@ -1991,6 +2033,11 @@ const completionSpec: Fig.Spec = { description: "Do not use cache on remote tasks", isRepeatable: false, }, + { + name: "--no-prepare", + description: "Skip automatic dependency preparation", + isRepeatable: false, + }, { name: "--no-timings", description: "Hides elapsed time after each task completes", @@ -2743,6 +2790,11 @@ const completionSpec: Fig.Spec = { description: "Do not use cache on remote tasks", isRepeatable: false, }, + { + name: "--no-prepare", + description: "Skip automatic dependency preparation", + isRepeatable: false, + }, { name: "--no-timings", description: "Hides elapsed time after each task completes",